4eed96f901
- Updated configuration to support three tasks in the assistant's prompt: rewriting questions, classifying intent for knowledge base retrieval, and analyzing attached images. - Implemented backend functionality to update image descriptions in user messages, ensuring that image context is preserved across conversation history. - Modified session handling to return complete session data after updates, improving the accuracy of session information provided to users. - Enhanced the chat pipeline to include detailed image descriptions and manage image captions effectively, enriching user interactions with visual content. These improvements significantly enhance the application's multimodal capabilities, providing a more robust and user-friendly experience when handling image-based queries.
690 lines
26 KiB
Go
690 lines
26 KiB
Go
package session
|
|
|
|
import (
|
|
"context"
|
|
"encoding/json"
|
|
"fmt"
|
|
"net/http"
|
|
"runtime"
|
|
"time"
|
|
|
|
"github.com/Tencent/WeKnora/internal/errors"
|
|
"github.com/Tencent/WeKnora/internal/event"
|
|
"github.com/Tencent/WeKnora/internal/logger"
|
|
"github.com/Tencent/WeKnora/internal/types"
|
|
secutils "github.com/Tencent/WeKnora/internal/utils"
|
|
"github.com/gin-gonic/gin"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// qaRequestContext holds all the common data needed for QA requests
|
|
type qaRequestContext struct {
|
|
ctx context.Context
|
|
c *gin.Context
|
|
sessionID string
|
|
requestID string
|
|
query string
|
|
session *types.Session
|
|
customAgent *types.CustomAgent
|
|
assistantMessage *types.Message
|
|
knowledgeBaseIDs []string
|
|
knowledgeIDs []string
|
|
summaryModelID string
|
|
webSearchEnabled bool
|
|
enableMemory bool // Whether memory feature is enabled
|
|
mentionedItems types.MentionedItems
|
|
effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant
|
|
images []ImageAttachment // Uploaded images with analysis text
|
|
userMessageID string // Created user message ID (populated after createUserMessage)
|
|
}
|
|
|
|
// parseQARequest parses and validates a QA request, returns the request context
|
|
func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestContext, *CreateKnowledgeQARequest, error) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
logger.Infof(ctx, "[%s] Start processing request", logPrefix)
|
|
|
|
// Get session ID from URL parameter
|
|
sessionID := secutils.SanitizeForLog(c.Param("session_id"))
|
|
if sessionID == "" {
|
|
logger.Error(ctx, "Session ID is empty")
|
|
return nil, nil, errors.NewBadRequestError(errors.ErrInvalidSessionID.Error())
|
|
}
|
|
|
|
// Parse request body
|
|
var request CreateKnowledgeQARequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
logger.Error(ctx, "Failed to parse request data", err)
|
|
return nil, nil, errors.NewBadRequestError(err.Error())
|
|
}
|
|
|
|
// Validate query content
|
|
if request.Query == "" {
|
|
logger.Error(ctx, "Query content is empty")
|
|
return nil, nil, errors.NewBadRequestError("Query content cannot be empty")
|
|
}
|
|
|
|
// Log request details
|
|
if requestJSON, err := json.Marshal(request); err == nil {
|
|
logger.Infof(ctx, "[%s] Request: session_id=%s, request=%s",
|
|
logPrefix, sessionID, secutils.SanitizeForLog(secutils.CompactImageDataURLForLog(string(requestJSON))))
|
|
}
|
|
|
|
// Get session
|
|
session, err := h.sessionService.GetSession(ctx, sessionID)
|
|
if err != nil {
|
|
logger.Errorf(ctx, "Failed to get session, session ID: %s, error: %v", sessionID, err)
|
|
return nil, nil, errors.NewNotFoundError("Session not found")
|
|
}
|
|
|
|
// Get custom agent if agent_id is provided. Backend resolves shared agent from share relation (no client-provided tenant).
|
|
var customAgent *types.CustomAgent
|
|
var effectiveTenantID uint64
|
|
if request.AgentID != "" {
|
|
logger.Infof(ctx, "Resolving agent, agent ID: %s", secutils.SanitizeForLog(request.AgentID))
|
|
userIDVal, _ := c.Get(types.UserIDContextKey.String())
|
|
currentTenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
if h.agentShareService != nil && userIDVal != nil && currentTenantID != 0 {
|
|
userID, _ := userIDVal.(string)
|
|
agent, err := h.agentShareService.GetSharedAgentForUser(ctx, userID, currentTenantID, request.AgentID)
|
|
if err == nil && agent != nil {
|
|
effectiveTenantID = agent.TenantID
|
|
customAgent = agent
|
|
logger.Infof(ctx, "Using shared agent: ID=%s, Name=%s, effectiveTenantID=%d (retrieval scope)",
|
|
customAgent.ID, customAgent.Name, effectiveTenantID)
|
|
}
|
|
}
|
|
if customAgent == nil {
|
|
agent, err := h.customAgentService.GetAgentByID(ctx, request.AgentID)
|
|
if err == nil {
|
|
customAgent = agent
|
|
logger.Infof(ctx, "Using own agent: ID=%s, Name=%s, AgentMode=%s",
|
|
customAgent.ID, customAgent.Name, customAgent.Config.AgentMode)
|
|
} else {
|
|
logger.Warnf(ctx, "Failed to get custom agent, agent ID: %s, error: %v, using default config",
|
|
secutils.SanitizeForLog(request.AgentID), err)
|
|
}
|
|
} else {
|
|
logger.Infof(ctx, "Using custom agent: ID=%s, Name=%s, IsBuiltin=%v, AgentMode=%s, effectiveTenantID=%d",
|
|
customAgent.ID, customAgent.Name, customAgent.IsBuiltin, customAgent.Config.AgentMode, effectiveTenantID)
|
|
}
|
|
}
|
|
|
|
// Merge @mentioned items into knowledge_base_ids and knowledge_ids so that
|
|
// retrieval (quick-answer and agent mode) uses the same targets the user @mentioned.
|
|
// This fixes the case where user only @mentions a (shared) KB in the input but
|
|
// does not select it in the sidebar — without this merge, retrieval would not search those KBs.
|
|
kbIDs := make([]string, 0, len(request.KnowledgeBaseIDs)+len(request.MentionedItems))
|
|
kbIDSet := make(map[string]bool)
|
|
for _, id := range request.KnowledgeBaseIDs {
|
|
if id != "" && !kbIDSet[id] {
|
|
kbIDs = append(kbIDs, id)
|
|
kbIDSet[id] = true
|
|
}
|
|
}
|
|
knowledgeIDs := make([]string, 0, len(request.KnowledgeIds)+len(request.MentionedItems))
|
|
knowledgeIDSet := make(map[string]bool)
|
|
for _, id := range request.KnowledgeIds {
|
|
if id != "" && !knowledgeIDSet[id] {
|
|
knowledgeIDs = append(knowledgeIDs, id)
|
|
knowledgeIDSet[id] = true
|
|
}
|
|
}
|
|
for _, item := range request.MentionedItems {
|
|
if item.ID == "" {
|
|
continue
|
|
}
|
|
switch item.Type {
|
|
case "kb":
|
|
if !kbIDSet[item.ID] {
|
|
kbIDs = append(kbIDs, item.ID)
|
|
kbIDSet[item.ID] = true
|
|
}
|
|
case "file":
|
|
if !knowledgeIDSet[item.ID] {
|
|
knowledgeIDs = append(knowledgeIDs, item.ID)
|
|
knowledgeIDSet[item.ID] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
// Log merge results for debugging
|
|
logger.Infof(ctx, "[%s] @mention merge: request.KnowledgeBaseIDs=%v, request.MentionedItems=%d, merged kbIDs=%v, merged knowledgeIDs=%v",
|
|
logPrefix, request.KnowledgeBaseIDs, len(request.MentionedItems), kbIDs, knowledgeIDs)
|
|
|
|
// Process inline base64 images: decode and save to storage.
|
|
// VLM analysis for RAG paths is deferred to the pipeline rewrite step.
|
|
// For pure chat paths with non-vision models, VLM analysis runs here as fallback.
|
|
if len(request.Images) > 0 {
|
|
if customAgent == nil || !customAgent.Config.ImageUploadEnabled {
|
|
logger.Warnf(ctx, "[%s] Image upload is not enabled for this agent, rejecting %d images", logPrefix, len(request.Images))
|
|
return nil, nil, errors.NewBadRequestError("Image upload is not enabled for this agent")
|
|
}
|
|
tenantID := c.GetUint64(types.TenantIDContextKey.String())
|
|
agentStorageProvider := customAgent.Config.ImageStorageProvider
|
|
if err := h.saveImageAttachments(ctx, request.Images, tenantID, agentStorageProvider); err != nil {
|
|
logger.Errorf(ctx, "[%s] Failed to save images: %v", logPrefix, err)
|
|
return nil, nil, errors.NewBadRequestError(fmt.Sprintf("Image save failed: %v", err))
|
|
}
|
|
|
|
// VLM analysis is always deferred to after SSE stream is up:
|
|
// - Agent mode: runs in async execution flow with tool_call/tool_result events
|
|
// - Normal RAG mode: runs in the pipeline rewrite step with progress events
|
|
// - Normal pure-chat mode: runs in the async goroutine with progress events
|
|
}
|
|
|
|
// Build request context
|
|
reqCtx := &qaRequestContext{
|
|
ctx: ctx,
|
|
c: c,
|
|
sessionID: sessionID,
|
|
requestID: secutils.SanitizeForLog(c.GetString(types.RequestIDContextKey.String())),
|
|
query: secutils.SanitizeForLog(request.Query),
|
|
session: session,
|
|
customAgent: customAgent,
|
|
assistantMessage: &types.Message{
|
|
SessionID: sessionID,
|
|
Role: "assistant",
|
|
RequestID: c.GetString(types.RequestIDContextKey.String()),
|
|
IsCompleted: false,
|
|
},
|
|
knowledgeBaseIDs: secutils.SanitizeForLogArray(kbIDs),
|
|
knowledgeIDs: secutils.SanitizeForLogArray(knowledgeIDs),
|
|
summaryModelID: secutils.SanitizeForLog(request.SummaryModelID),
|
|
webSearchEnabled: request.WebSearchEnabled,
|
|
enableMemory: request.EnableMemory,
|
|
mentionedItems: convertMentionedItems(request.MentionedItems),
|
|
effectiveTenantID: effectiveTenantID,
|
|
images: request.Images,
|
|
}
|
|
|
|
return reqCtx, &request, nil
|
|
}
|
|
|
|
// sseStreamContext holds the context for SSE streaming
|
|
type sseStreamContext struct {
|
|
eventBus *event.EventBus
|
|
asyncCtx context.Context
|
|
cancel context.CancelFunc
|
|
assistantMessage *types.Message
|
|
}
|
|
|
|
// setupSSEStream sets up the SSE streaming context
|
|
func (h *Handler) setupSSEStream(reqCtx *qaRequestContext, generateTitle bool) *sseStreamContext {
|
|
// Set SSE headers
|
|
setSSEHeaders(reqCtx.c)
|
|
|
|
// Write initial agent_query event
|
|
h.writeAgentQueryEvent(reqCtx.ctx, reqCtx.sessionID, reqCtx.assistantMessage.ID)
|
|
|
|
// Base context for async work: when using shared agent, use source tenant for model/KB/MCP resolution
|
|
baseCtx := reqCtx.ctx
|
|
if reqCtx.effectiveTenantID != 0 && h.tenantService != nil {
|
|
if tenant, err := h.tenantService.GetTenantByID(reqCtx.ctx, reqCtx.effectiveTenantID); err == nil && tenant != nil {
|
|
baseCtx = context.WithValue(context.WithValue(reqCtx.ctx, types.TenantIDContextKey, reqCtx.effectiveTenantID), types.TenantInfoContextKey, tenant)
|
|
logger.Infof(reqCtx.ctx, "Using effective tenant %d for shared agent (model/KB/MCP)", reqCtx.effectiveTenantID)
|
|
}
|
|
}
|
|
|
|
// Create EventBus and cancellable context
|
|
eventBus := event.NewEventBus()
|
|
asyncCtx, cancel := context.WithCancel(logger.CloneContext(baseCtx))
|
|
|
|
streamCtx := &sseStreamContext{
|
|
eventBus: eventBus,
|
|
asyncCtx: asyncCtx,
|
|
cancel: cancel,
|
|
assistantMessage: reqCtx.assistantMessage,
|
|
}
|
|
|
|
// Setup stop event handler
|
|
h.setupStopEventHandler(eventBus, reqCtx.sessionID, reqCtx.session.TenantID, reqCtx.assistantMessage, cancel)
|
|
|
|
// Setup stream handler
|
|
h.setupStreamHandler(asyncCtx, reqCtx.sessionID, reqCtx.assistantMessage.ID,
|
|
reqCtx.requestID, reqCtx.assistantMessage, eventBus)
|
|
|
|
// Generate title if needed
|
|
if generateTitle && reqCtx.session.Title == "" {
|
|
// Use the same model as the conversation for title generation
|
|
modelID := ""
|
|
if reqCtx.customAgent != nil && reqCtx.customAgent.Config.ModelID != "" {
|
|
modelID = reqCtx.customAgent.Config.ModelID
|
|
}
|
|
logger.Infof(reqCtx.ctx, "Session has no title, starting async title generation, session ID: %s, model: %s", reqCtx.sessionID, modelID)
|
|
h.sessionService.GenerateTitleAsync(asyncCtx, reqCtx.session, reqCtx.query, modelID, eventBus)
|
|
}
|
|
|
|
return streamCtx
|
|
}
|
|
|
|
// SearchKnowledge godoc
|
|
// @Summary 知识搜索
|
|
// @Description 在知识库中搜索(不使用LLM总结)
|
|
// @Tags 问答
|
|
// @Accept json
|
|
// @Produce json
|
|
// @Param request body SearchKnowledgeRequest true "搜索请求"
|
|
// @Success 200 {object} map[string]interface{} "搜索结果"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /sessions/search [post]
|
|
func (h *Handler) SearchKnowledge(c *gin.Context) {
|
|
ctx := logger.CloneContext(c.Request.Context())
|
|
logger.Info(ctx, "Start processing knowledge search request")
|
|
|
|
// Parse request body
|
|
var request SearchKnowledgeRequest
|
|
if err := c.ShouldBindJSON(&request); err != nil {
|
|
logger.Error(ctx, "Failed to parse request data", err)
|
|
c.Error(errors.NewBadRequestError(err.Error()))
|
|
return
|
|
}
|
|
|
|
// Validate request parameters
|
|
if request.Query == "" {
|
|
logger.Error(ctx, "Query content is empty")
|
|
c.Error(errors.NewBadRequestError("Query content cannot be empty"))
|
|
return
|
|
}
|
|
|
|
// Merge single knowledge_base_id into knowledge_base_ids for backward compatibility
|
|
knowledgeBaseIDs := request.KnowledgeBaseIDs
|
|
if request.KnowledgeBaseID != "" {
|
|
// Check if it's already in the list to avoid duplicates
|
|
found := false
|
|
for _, id := range knowledgeBaseIDs {
|
|
if id == request.KnowledgeBaseID {
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
knowledgeBaseIDs = append(knowledgeBaseIDs, request.KnowledgeBaseID)
|
|
}
|
|
}
|
|
|
|
if len(knowledgeBaseIDs) == 0 && len(request.KnowledgeIDs) == 0 {
|
|
logger.Error(ctx, "No knowledge base IDs or knowledge IDs provided")
|
|
c.Error(errors.NewBadRequestError("At least one knowledge_base_id, knowledge_base_ids or knowledge_ids must be provided"))
|
|
return
|
|
}
|
|
|
|
logger.Infof(
|
|
ctx,
|
|
"Knowledge search request, knowledge base IDs: %v, knowledge IDs: %v, query: %s",
|
|
secutils.SanitizeForLogArray(knowledgeBaseIDs),
|
|
secutils.SanitizeForLogArray(request.KnowledgeIDs),
|
|
secutils.SanitizeForLog(request.Query),
|
|
)
|
|
|
|
// Directly call knowledge retrieval service without LLM summarization
|
|
searchResults, err := h.sessionService.SearchKnowledge(ctx, knowledgeBaseIDs, request.KnowledgeIDs, request.Query)
|
|
if err != nil {
|
|
logger.ErrorWithFields(ctx, err, nil)
|
|
c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Knowledge search completed, found %d results", len(searchResults))
|
|
c.JSON(http.StatusOK, gin.H{
|
|
"success": true,
|
|
"data": searchResults,
|
|
})
|
|
}
|
|
|
|
// KnowledgeQA godoc
|
|
// @Summary 知识问答
|
|
// @Description 基于知识库的问答(使用LLM总结),支持SSE流式响应
|
|
// @Tags 问答
|
|
// @Accept json
|
|
// @Produce text/event-stream
|
|
// @Param session_id path string true "会话ID"
|
|
// @Param request body CreateKnowledgeQARequest true "问答请求"
|
|
// @Success 200 {object} map[string]interface{} "问答结果(SSE流)"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /sessions/{session_id}/knowledge-qa [post]
|
|
func (h *Handler) KnowledgeQA(c *gin.Context) {
|
|
// Parse and validate request
|
|
reqCtx, request, err := h.parseQARequest(c, "KnowledgeQA")
|
|
if err != nil {
|
|
c.Error(err)
|
|
return
|
|
}
|
|
|
|
// Execute normal mode QA, generate title unless disabled
|
|
h.executeNormalModeQA(reqCtx, !request.DisableTitle)
|
|
}
|
|
|
|
// AgentQA godoc
|
|
// @Summary Agent问答
|
|
// @Description 基于Agent的智能问答,支持多轮对话和SSE流式响应
|
|
// @Tags 问答
|
|
// @Accept json
|
|
// @Produce text/event-stream
|
|
// @Param session_id path string true "会话ID"
|
|
// @Param request body CreateKnowledgeQARequest true "问答请求"
|
|
// @Success 200 {object} map[string]interface{} "问答结果(SSE流)"
|
|
// @Failure 400 {object} errors.AppError "请求参数错误"
|
|
// @Security Bearer
|
|
// @Security ApiKeyAuth
|
|
// @Router /sessions/{session_id}/agent-qa [post]
|
|
func (h *Handler) AgentQA(c *gin.Context) {
|
|
// Parse and validate request
|
|
reqCtx, request, err := h.parseQARequest(c, "AgentQA")
|
|
if err != nil {
|
|
c.Error(err)
|
|
return
|
|
}
|
|
|
|
// Determine if agent mode should be enabled
|
|
// Priority: customAgent.IsAgentMode() > request.AgentEnabled
|
|
agentModeEnabled := request.AgentEnabled
|
|
if reqCtx.customAgent != nil {
|
|
agentModeEnabled = reqCtx.customAgent.IsAgentMode()
|
|
logger.Infof(reqCtx.ctx, "Agent mode determined by custom agent: %v (config.agent_mode=%s)",
|
|
agentModeEnabled, reqCtx.customAgent.Config.AgentMode)
|
|
}
|
|
|
|
// Route to appropriate handler based on agent mode
|
|
if agentModeEnabled {
|
|
h.executeAgentModeQA(reqCtx)
|
|
} else {
|
|
logger.Infof(reqCtx.ctx, "Agent mode disabled, delegating to normal mode for session: %s", reqCtx.sessionID)
|
|
// Knowledge bases should be specified in request or from custom agent
|
|
h.executeNormalModeQA(reqCtx, false)
|
|
}
|
|
}
|
|
|
|
// executeNormalModeQA executes the normal (KnowledgeQA) mode
|
|
func (h *Handler) executeNormalModeQA(reqCtx *qaRequestContext, generateTitle bool) {
|
|
ctx := reqCtx.ctx
|
|
sessionID := reqCtx.sessionID
|
|
|
|
// Create user message
|
|
userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images))
|
|
if err != nil {
|
|
reqCtx.c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
reqCtx.userMessageID = userMsg.ID
|
|
|
|
// Create assistant message
|
|
if _, err := h.createAssistantMessage(ctx, reqCtx.assistantMessage); err != nil {
|
|
reqCtx.c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
|
|
logger.Infof(ctx, "Using knowledge bases: %v", reqCtx.knowledgeBaseIDs)
|
|
|
|
// Setup SSE stream
|
|
streamCtx := h.setupSSEStream(reqCtx, generateTitle)
|
|
|
|
// Setup completion handler for normal mode
|
|
// Note: Thinking content is now embedded in answer stream with <think> tags
|
|
// by chat_completion_stream.go, so we don't need separate thinking event handling
|
|
var completionHandled bool // Prevent duplicate completion handling
|
|
|
|
streamCtx.eventBus.On(event.EventAgentFinalAnswer, func(ctx context.Context, evt event.Event) error {
|
|
data, ok := evt.Data.(event.AgentFinalAnswerData)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
streamCtx.assistantMessage.Content += data.Content
|
|
if data.IsFallback {
|
|
streamCtx.assistantMessage.IsFallback = true
|
|
}
|
|
if data.Done {
|
|
// Prevent duplicate completion handling
|
|
if completionHandled {
|
|
return nil
|
|
}
|
|
completionHandled = true
|
|
|
|
logger.Infof(streamCtx.asyncCtx, "Knowledge QA service completed for session: %s", sessionID)
|
|
// Content already contains <think>...</think> tags from chat_completion_stream.go
|
|
// Use session's tenant for message update (asyncCtx may have effectiveTenantID when using shared agent)
|
|
updateCtx := context.WithValue(streamCtx.asyncCtx, types.TenantIDContextKey, reqCtx.session.TenantID)
|
|
h.completeAssistantMessage(updateCtx, streamCtx.assistantMessage, reqCtx.query)
|
|
// Emit EventAgentComplete - this will trigger handleComplete which sends the SSE complete event
|
|
// Note: Don't cancel context here, let the SSE handler close naturally after receiving the complete event
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventAgentComplete,
|
|
SessionID: sessionID,
|
|
Data: event.AgentCompleteData{FinalAnswer: streamCtx.assistantMessage.Content},
|
|
})
|
|
}
|
|
return nil
|
|
})
|
|
|
|
// Execute KnowledgeQA asynchronously
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
buf := make([]byte, 10240)
|
|
runtime.Stack(buf, true)
|
|
logger.ErrorWithFields(streamCtx.asyncCtx,
|
|
errors.NewInternalServerError(fmt.Sprintf("Knowledge QA service panicked: %v\n%s", r, string(buf))), nil)
|
|
}
|
|
}()
|
|
|
|
// For pure-chat path (no KB, no web search), VLM analysis isn't handled by the
|
|
// rewrite pipeline step, so run it here with progress events.
|
|
// RAG paths (with KB/web search, including agent-resolved KBs) defer VLM analysis
|
|
// to the rewrite step to avoid duplicate progress events.
|
|
hasRequestKBs := len(reqCtx.knowledgeBaseIDs) > 0 || len(reqCtx.knowledgeIDs) > 0
|
|
agentWillResolveKBs := false
|
|
if !hasRequestKBs && reqCtx.customAgent != nil && !reqCtx.customAgent.Config.RetrieveKBOnlyWhenMentioned {
|
|
mode := reqCtx.customAgent.Config.KBSelectionMode
|
|
switch mode {
|
|
case "all":
|
|
agentWillResolveKBs = true
|
|
case "selected", "":
|
|
// "" keeps backward-compatible behavior (same as selected).
|
|
agentWillResolveKBs = len(reqCtx.customAgent.Config.KnowledgeBases) > 0
|
|
case "none":
|
|
agentWillResolveKBs = false
|
|
default:
|
|
agentWillResolveKBs = len(reqCtx.customAgent.Config.KnowledgeBases) > 0
|
|
}
|
|
}
|
|
isPureChatPath := !hasRequestKBs && !agentWillResolveKBs && !reqCtx.webSearchEnabled
|
|
if isPureChatPath && len(reqCtx.images) > 0 &&
|
|
reqCtx.customAgent != nil && reqCtx.customAgent.Config.VLMModelID != "" {
|
|
toolCallID := uuid.New().String()
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventAgentToolCall,
|
|
SessionID: sessionID,
|
|
Data: event.AgentToolCallData{
|
|
ToolCallID: toolCallID,
|
|
ToolName: "image_analysis",
|
|
},
|
|
})
|
|
vlmStart := time.Now()
|
|
h.analyzeImageAttachments(streamCtx.asyncCtx, reqCtx.images,
|
|
reqCtx.customAgent.Config.VLMModelID, reqCtx.query)
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventAgentToolResult,
|
|
SessionID: sessionID,
|
|
Data: event.AgentToolResultData{
|
|
ToolCallID: toolCallID,
|
|
ToolName: "image_analysis",
|
|
Output: "已分析图片内容",
|
|
Success: true,
|
|
Duration: time.Since(vlmStart).Milliseconds(),
|
|
},
|
|
})
|
|
}
|
|
|
|
imageURLs, imageDescription := extractImageURLsAndOCRText(reqCtx.images)
|
|
err := h.sessionService.KnowledgeQA(
|
|
streamCtx.asyncCtx,
|
|
reqCtx.session,
|
|
reqCtx.query,
|
|
reqCtx.knowledgeBaseIDs,
|
|
reqCtx.knowledgeIDs,
|
|
reqCtx.assistantMessage.ID,
|
|
reqCtx.summaryModelID,
|
|
reqCtx.webSearchEnabled,
|
|
streamCtx.eventBus,
|
|
reqCtx.customAgent,
|
|
reqCtx.enableMemory,
|
|
imageURLs, imageDescription,
|
|
reqCtx.userMessageID,
|
|
)
|
|
if err != nil {
|
|
logger.ErrorWithFields(streamCtx.asyncCtx, err, nil)
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventError,
|
|
SessionID: sessionID,
|
|
Data: event.ErrorData{
|
|
Error: err.Error(),
|
|
Stage: "knowledge_qa_execution",
|
|
SessionID: sessionID,
|
|
},
|
|
})
|
|
}
|
|
}()
|
|
|
|
// Handle SSE events (blocking)
|
|
shouldWaitForTitle := generateTitle && reqCtx.session.Title == ""
|
|
h.handleAgentEventsForSSE(ctx, reqCtx.c, sessionID, reqCtx.assistantMessage.ID,
|
|
reqCtx.requestID, streamCtx.eventBus, shouldWaitForTitle)
|
|
}
|
|
|
|
// executeAgentModeQA executes the agent mode
|
|
func (h *Handler) executeAgentModeQA(reqCtx *qaRequestContext) {
|
|
ctx := reqCtx.ctx
|
|
sessionID := reqCtx.sessionID
|
|
|
|
// Emit agent query event
|
|
if err := event.Emit(ctx, event.Event{
|
|
Type: event.EventAgentQuery,
|
|
SessionID: sessionID,
|
|
RequestID: reqCtx.requestID,
|
|
Data: event.AgentQueryData{
|
|
SessionID: sessionID,
|
|
Query: reqCtx.query,
|
|
RequestID: reqCtx.requestID,
|
|
},
|
|
}); err != nil {
|
|
logger.Errorf(ctx, "Failed to emit agent query event: %v", err)
|
|
return
|
|
}
|
|
|
|
// Create user message
|
|
userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images))
|
|
if err != nil {
|
|
reqCtx.c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
reqCtx.userMessageID = userMsg.ID
|
|
|
|
// Create assistant message
|
|
assistantMessagePtr, err := h.createAssistantMessage(ctx, reqCtx.assistantMessage)
|
|
if err != nil {
|
|
reqCtx.c.Error(errors.NewInternalServerError(err.Error()))
|
|
return
|
|
}
|
|
reqCtx.assistantMessage = assistantMessagePtr
|
|
|
|
logger.Infof(ctx, "Calling agent QA service, session ID: %s", sessionID)
|
|
|
|
// Setup SSE stream (agent mode always generates title)
|
|
streamCtx := h.setupSSEStream(reqCtx, true)
|
|
|
|
// Execute AgentQA asynchronously
|
|
go func() {
|
|
defer func() {
|
|
if r := recover(); r != nil {
|
|
buf := make([]byte, 1024)
|
|
runtime.Stack(buf, true)
|
|
logger.ErrorWithFields(streamCtx.asyncCtx,
|
|
errors.NewInternalServerError(fmt.Sprintf("Agent QA service panicked: %v\n%s", r, string(buf))),
|
|
map[string]interface{}{"session_id": sessionID})
|
|
}
|
|
// Use session's tenant for message update (session belongs to session.TenantID; asyncCtx may have effectiveTenantID when using shared agent)
|
|
updateCtx := context.WithValue(streamCtx.asyncCtx, types.TenantIDContextKey, reqCtx.session.TenantID)
|
|
h.completeAssistantMessage(updateCtx, streamCtx.assistantMessage, reqCtx.query)
|
|
logger.Infof(streamCtx.asyncCtx, "Agent QA service completed for session: %s", sessionID)
|
|
}()
|
|
|
|
// Run VLM analysis inside the async flow so SSE stream is already up
|
|
// and the user can see progress. Emit tool_call/tool_result events.
|
|
if len(reqCtx.images) > 0 && reqCtx.customAgent != nil && reqCtx.customAgent.Config.VLMModelID != "" {
|
|
toolCallID := uuid.New().String()
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventAgentToolCall,
|
|
SessionID: sessionID,
|
|
Data: event.AgentToolCallData{
|
|
ToolCallID: toolCallID,
|
|
ToolName: "image_analysis",
|
|
Iteration: 0,
|
|
},
|
|
})
|
|
vlmStart := time.Now()
|
|
h.analyzeImageAttachments(streamCtx.asyncCtx, reqCtx.images,
|
|
reqCtx.customAgent.Config.VLMModelID, reqCtx.query)
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventAgentToolResult,
|
|
SessionID: sessionID,
|
|
Data: event.AgentToolResultData{
|
|
ToolCallID: toolCallID,
|
|
ToolName: "image_analysis",
|
|
Output: "已查看图片内容",
|
|
Success: true,
|
|
Duration: time.Since(vlmStart).Milliseconds(),
|
|
Iteration: 0,
|
|
},
|
|
})
|
|
}
|
|
|
|
imageURLs, imageDescription := extractImageURLsAndOCRText(reqCtx.images)
|
|
err := h.sessionService.AgentQA(
|
|
streamCtx.asyncCtx,
|
|
reqCtx.session,
|
|
reqCtx.query,
|
|
reqCtx.assistantMessage.ID,
|
|
reqCtx.summaryModelID,
|
|
streamCtx.eventBus,
|
|
reqCtx.customAgent,
|
|
reqCtx.knowledgeBaseIDs,
|
|
reqCtx.knowledgeIDs,
|
|
imageURLs, imageDescription,
|
|
reqCtx.userMessageID,
|
|
)
|
|
if err != nil {
|
|
logger.ErrorWithFields(streamCtx.asyncCtx, err, nil)
|
|
streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{
|
|
Type: event.EventError,
|
|
SessionID: sessionID,
|
|
Data: event.ErrorData{
|
|
Error: err.Error(),
|
|
Stage: "agent_execution",
|
|
SessionID: sessionID,
|
|
},
|
|
})
|
|
}
|
|
}()
|
|
|
|
// Handle SSE events (blocking)
|
|
h.handleAgentEventsForSSE(ctx, reqCtx.c, sessionID, reqCtx.assistantMessage.ID,
|
|
reqCtx.requestID, streamCtx.eventBus, reqCtx.session.Title == "")
|
|
}
|
|
|
|
// completeAssistantMessage marks an assistant message as complete, updates it,
|
|
// and asynchronously indexes the Q&A pair into the chat history knowledge base.
|
|
func (h *Handler) completeAssistantMessage(ctx context.Context, assistantMessage *types.Message, userQuery string) {
|
|
assistantMessage.UpdatedAt = time.Now()
|
|
assistantMessage.IsCompleted = true
|
|
_ = h.messageService.UpdateMessage(ctx, assistantMessage)
|
|
|
|
// Asynchronously index the Q&A pair into the chat history knowledge base for vector search.
|
|
// Use WithoutCancel so the goroutine survives after the HTTP request context is done.
|
|
bgCtx := context.WithoutCancel(ctx)
|
|
go h.messageService.IndexMessageToKB(bgCtx, userQuery, assistantMessage.Content, assistantMessage.ID, assistantMessage.SessionID)
|
|
}
|
|
|