package session import ( "context" "crypto/sha256" "encoding/json" "fmt" "net/http" "os" "path/filepath" "runtime" "strconv" "strings" "sync" "time" "github.com/Tencent/WeKnora/internal/errors" "github.com/Tencent/WeKnora/internal/event" "github.com/Tencent/WeKnora/internal/logger" "github.com/Tencent/WeKnora/internal/storageurl" "github.com/Tencent/WeKnora/internal/tracing/langfuse" "github.com/Tencent/WeKnora/internal/types" secutils "github.com/Tencent/WeKnora/internal/utils" "github.com/gin-gonic/gin" "github.com/google/uuid" ) const ( maxAttachmentUploadsPerRequest = 5 maxAttachmentUploadTotalBytes = int64(100 * 1024 * 1024) ) // qaRequestContext holds all the common data needed for QA requests type qaRequestContext struct { ctx context.Context c *gin.Context sessionID string requestID string receivedAt time.Time // Wall-clock time the handler started processing the request query string session *types.Session customAgent *types.CustomAgent assistantMessage *types.Message knowledgeBaseIDs []string knowledgeIDs []string tagScopes []types.TagScope tagIDs []string mcpServiceIDs []string skillNames []string summaryModelID string webSearchEnabled bool mentionedItems types.MentionedItems effectiveTenantID uint64 // when using shared agent, tenant ID for model/KB/MCP resolution; 0 = use context tenant sharedAgentReadOnly bool // access was granted by a read-only agent share images []ImageAttachment // Uploaded images with analysis text userMessageID string // Created user message ID (populated after createUserMessage) userCreatedAt time.Time // Persisted user message timestamp, echoed on agent_query channel string // Source channel: "web", "api", "im", etc. attachments types.MessageAttachments // Processed base64 file attachments (legacy inline uploads) attachmentIDs []string // Pre-uploaded session-scoped document IDs, resolved after SSE starts attachmentMetas types.MessageAttachments // Metadata-only view of attachmentIDs for the persisted user message suggestionAttribution *types.SuggestionAttribution // resourceRewriter turns internal storage references in the outbound stream // into directly loadable URLs when the caller asks for `resource_urls=public`. // Disabled (a pass-through) in the default handle mode. resourceRewriter *storageurl.StreamRewriter // Snapshot of the request fields needed to persist the input-bar state // for session restoration. Kept verbatim from the request so we record // what the user had selected on the UI (not server-side resolutions). reqAgentEnabled bool reqAgentID string } // buildQARequest converts the qaRequestContext into a types.QARequest for service invocation. func (rc *qaRequestContext) buildQARequest() *types.QARequest { imageURLs, imageDescription := extractImageURLsAndOCRText(rc.images) return &types.QARequest{ Session: rc.session, Query: rc.query, AssistantMessageID: rc.assistantMessage.ID, SummaryModelID: rc.summaryModelID, CustomAgent: rc.customAgent, SharedAgentReadOnly: rc.sharedAgentReadOnly, KnowledgeBaseIDs: rc.knowledgeBaseIDs, KnowledgeIDs: rc.knowledgeIDs, TagScopes: rc.tagScopes, MCPServiceIDs: rc.mcpServiceIDs, SkillNames: rc.skillNames, ImageURLs: imageURLs, ImageDescription: imageDescription, UserMessageID: rc.userMessageID, WebSearchEnabled: rc.webSearchEnabled, Attachments: rc.attachments, } } // parseQARequest parses and validates a QA request, returns the request context func (h *Handler) parseQARequest(c *gin.Context, logPrefix string) (*qaRequestContext, *CreateKnowledgeQARequest, error) { receivedAt := time.Now() ctx := logger.CloneContext(c.Request.Context()) requestID := secutils.SanitizeForLog(c.GetString(types.RequestIDContextKey.String())) logger.Infof(ctx, "[%s] TTFB:start request_id=%s received_at=%d", logPrefix, requestID, receivedAt.UnixMilli()) // 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") } // Resolve the storage-reference representation up front: once the SSE stream // has started an invalid value can no longer be reported as a 400. resourceRewriter, err := h.resolveStreamRewriter(c) if err != nil { logger.Warnf(ctx, "Rejected resource URL mode: %v", err) return nil, nil, err } if h.suggestionService != nil && request.SuggestionAttribution != nil { if err := h.suggestionService.ValidateAttribution(ctx, sessionID, request.Query, request.SuggestionAttribution); err != nil { return nil, nil, errors.NewBadRequestError("invalid suggestion attribution") } } // SSRF protection: strip client-supplied URL/Caption fields from image attachments. // The URL field must only be populated server-side by saveImageAttachments; an // attacker could inject internal network URLs to trigger SSRF via the LLM provider. for i := range request.Images { request.Images[i].URL = "" request.Images[i].Caption = "" } // 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. QA writes new messages into the session, so use the strict // owner scope: a tenant admin may read an API-key session but must not be // able to post messages to it (which would otherwise fail later at message // creation with a 500 instead of a clean not-found). session, err := h.sessionService.GetOwnedSession(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). customAgent, effectiveTenantID, sharedAgentReadOnly := h.resolveAgent(ctx, c, request.AgentID, request.AgentSourceTenantID) if request.AgentSourceTenantID != 0 && customAgent == nil { return nil, nil, errors.NewNotFoundError("Shared agent not found") } // Merge @mentioned items into knowledge_base_ids and knowledge_ids kbIDs, knowledgeIDs := mergeKnowledgeTargets(request.KnowledgeBaseIDs, request.KnowledgeIds, request.MentionedItems) if err := types.AuthorizeTenantAPIKeyKnowledgeTargets(ctx, kbIDs, knowledgeIDs); err != nil { return nil, nil, err } // The built-in wiki fixer is invoked from a KB page, not from a tenant's // regular agent picker. When the KB is shared, run it in the source tenant // only if the caller has edit permission, so KB-scoped models/tools resolve // without granting viewers write capability. if customAgent != nil && customAgent.ID == types.BuiltinWikiFixerID { if scopedAgent, scopedTenantID := h.resolveWikiFixerTenantScope( ctx, customAgent, c.GetUint64(types.TenantIDContextKey.String()), types.TenantRoleFromContext(ctx), kbIDs, ); scopedTenantID != 0 { customAgent = scopedAgent effectiveTenantID = scopedTenantID sharedAgentReadOnly = false } } // 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 } // Process file attachments: decode and save to storage, extract content var processedAttachments types.MessageAttachments if len(request.AttachmentUploads) > 0 { logger.Infof(ctx, "[%s] processing %d attachment(s)", logPrefix, len(request.AttachmentUploads)) // Decode first and validate actual bytes. Client-declared file_size is // metadata only and must not be trusted for memory or sandbox limits. maxSize := secutils.GetMaxFileSize() decodedAttachments, decodeErr := decodeAndValidateAttachmentUploads( request.AttachmentUploads, maxAttachmentUploadsPerRequest, maxSize, maxAttachmentUploadTotalBytes, ) if decodeErr != nil { return nil, nil, errors.NewBadRequestError(decodeErr.Error()) } tenantID := c.GetUint64(types.TenantIDContextKey.String()) attachmentRuntimeCtx := ctx if effectiveTenantID != 0 { attachmentRuntimeCtx = context.WithValue(ctx, types.TenantIDContextKey, effectiveTenantID) } // Use ASR only when the agent has audio upload enabled. asrModelID := "" if customAgent != nil && customAgent.Config.AudioUploadEnabled && customAgent.Config.ASRModelID != "" { asrModelID = customAgent.Config.ASRModelID } // Resolve the agent's chat parser engine from attachment file types if customAgent != nil { for _, att := range request.AttachmentUploads { ext := strings.ToLower(filepath.Ext(att.FileName)) if engine := customAgent.Config.ResolveChatParserEngine(ext); engine != "" { attachmentRuntimeCtx = context.WithValue(attachmentRuntimeCtx, types.ChatParserEngineContextKey, engine) break } } } // Process all attachments concurrently. processedAttachments = make(types.MessageAttachments, len(request.AttachmentUploads)) var wg sync.WaitGroup errChan := make(chan error, len(request.AttachmentUploads)) for i, upload := range request.AttachmentUploads { wg.Add(1) go func(idx int, att AttachmentUpload) { defer wg.Done() processed, err := h.attachmentProcessor.ProcessAttachment( attachmentRuntimeCtx, decodedAttachments[idx], att.FileName, int64(len(decodedAttachments[idx])), tenantID, asrModelID, ) if err != nil { errChan <- fmt.Errorf("attachment %d processing failed: %w", idx+1, err) return } processedAttachments[idx] = *processed }(i, upload) } wg.Wait() close(errChan) if len(errChan) > 0 { err := <-errChan logger.Errorf(ctx, "[%s] attachment processing failed: %v", logPrefix, err) return nil, nil, errors.NewBadRequestError(fmt.Sprintf("attachment processing failed: %v", err)) } logger.Infof(ctx, "[%s] all attachments processed", logPrefix) } // Pre-uploaded documents may still be parsing. Only fetch their metadata // here (fast, available even while processing) to validate agent file-type // limits and to record the attachments on the persisted user message. The // heavy content selection happens after the SSE stream is up, so the send // is not blocked while parsing finishes (see resolveTemporaryAttachments). var attachmentIDs []string var attachmentMetas types.MessageAttachments if len(request.AttachmentIDs) > 0 { normalizedIDs, normErr := normalizeTemporaryAttachmentIDs(request.AttachmentIDs) if normErr != nil { return nil, nil, errors.NewBadRequestError(normErr.Error()) } tenantID := session.TenantID attachmentMetas = make(types.MessageAttachments, 0, len(normalizedIDs)) for _, id := range normalizedIDs { doc, getErr := h.temporaryDocuments.Get(ctx, tenantID, sessionID, id) if getErr != nil || doc == nil { return nil, nil, errors.NewBadRequestError( fmt.Sprintf("attachment %s was not found in this session", secutils.SanitizeForLog(id))) } if customAgent != nil && len(customAgent.Config.SupportedFileTypes) > 0 { ext := strings.TrimPrefix(strings.ToLower(doc.FileType), ".") if !containsFileType(customAgent.Config.SupportedFileTypes, ext) { return nil, nil, errors.NewBadRequestError( fmt.Sprintf("file type %s is not supported by this agent", ext)) } } attachmentMetas = append(attachmentMetas, types.MessageAttachment{ ID: doc.ID, URL: doc.ResourceRef, FileName: doc.FileName, FileType: doc.FileType, FileSize: doc.FileSize, }) } attachmentIDs = normalizedIDs } mentionScopes := tagScopesFromMentionedItems(request.MentionedItems) requestTagIDs := dedupRequestStrings(request.TagIDs) if err := validateUnscopedTagIDs(orphanTagIDsForScope(requestTagIDs, mentionScopes), secutils.SanitizeForLogArray(kbIDs)); err != nil { return nil, nil, errors.NewBadRequestError(err.Error()) } tagScopes := mergeTagScopesFromRequestIDs(mentionScopes, requestTagIDs, secutils.SanitizeForLogArray(kbIDs)) tagIDs := dedupRequestStrings(append(request.TagIDs, mentionedIDsByType(request.MentionedItems, "tag")...)) mcpServiceIDs := dedupRequestStrings(append(request.MCPServiceIDs, mentionedIDsByType(request.MentionedItems, "mcp")...)) skillNames := dedupRequestStrings(append(request.SkillNames, mentionedIDsByType(request.MentionedItems, "skill")...)) executionContext, agentID, agentTenantID, modelID := buildMessageExecutionContext( ctx, customAgent, effectiveTenantID, request.SummaryModelID, secutils.SanitizeForLogArray(kbIDs), secutils.SanitizeForLogArray(knowledgeIDs), secutils.SanitizeForLogArray(tagIDs), tagScopes, secutils.SanitizeForLogArray(mcpServiceIDs), secutils.SanitizeForLogArray(skillNames), request.WebSearchEnabled, ) // Build request context reqCtx := &qaRequestContext{ ctx: ctx, c: c, sessionID: sessionID, requestID: requestID, receivedAt: receivedAt, query: request.Query, session: session, customAgent: customAgent, assistantMessage: &types.Message{ SessionID: sessionID, Role: "assistant", RequestID: c.GetString(types.RequestIDContextKey.String()), IsCompleted: false, Channel: request.Channel, AgentID: agentID, AgentTenantID: agentTenantID, ModelID: modelID, ExecutionContext: executionContext, }, knowledgeBaseIDs: secutils.SanitizeForLogArray(kbIDs), knowledgeIDs: secutils.SanitizeForLogArray(knowledgeIDs), tagScopes: tagScopes, tagIDs: secutils.SanitizeForLogArray(tagIDs), mcpServiceIDs: secutils.SanitizeForLogArray(mcpServiceIDs), skillNames: secutils.SanitizeForLogArray(skillNames), summaryModelID: secutils.SanitizeForLog(request.SummaryModelID), webSearchEnabled: request.WebSearchEnabled, mentionedItems: convertMentionedItems(request.MentionedItems), effectiveTenantID: effectiveTenantID, sharedAgentReadOnly: sharedAgentReadOnly, images: request.Images, channel: request.Channel, attachments: processedAttachments, attachmentIDs: attachmentIDs, attachmentMetas: attachmentMetas, suggestionAttribution: request.SuggestionAttribution, reqAgentEnabled: request.AgentEnabled, reqAgentID: request.AgentID, resourceRewriter: resourceRewriter, } return reqCtx, &request, nil } func decodeAndValidateAttachmentUploads( uploads []AttachmentUpload, maxCount int, maxFileBytes, maxTotalBytes int64, ) ([][]byte, error) { if len(uploads) > maxCount { return nil, fmt.Errorf("at most %d attachments are allowed per request", maxCount) } decoded := make([][]byte, len(uploads)) var total int64 for i, upload := range uploads { data, err := DecodeBase64Attachment(upload.Data) if err != nil { return nil, fmt.Errorf("attachment %d decode failed: %w", i+1, err) } actualSize := int64(len(data)) if actualSize > maxFileBytes { return nil, fmt.Errorf("attachment %d exceeds size limit of %d bytes", i+1, maxFileBytes) } total += actualSize if total > maxTotalBytes { return nil, fmt.Errorf("attachments exceed total request limit of %d bytes", maxTotalBytes) } decoded[i] = data } return decoded, nil } func buildMessageExecutionContext( ctx context.Context, agent *types.CustomAgent, effectiveTenantID uint64, modelOverride string, knowledgeBaseIDs []string, knowledgeIDs []string, tagIDs []string, tagScopes []types.TagScope, mcpServiceIDs []string, skillNames []string, webSearchEnabled bool, ) (types.MessageExecutionContext, string, uint64, string) { locale := types.LanguageFromContextOrDefault(ctx) snapshot := types.MessageExecutionContext{ KnowledgeBaseIDs: knowledgeBaseIDs, KnowledgeIDs: knowledgeIDs, TagIDs: tagIDs, TagScopes: cloneTagScopes(tagScopes), MCPServiceIDs: mcpServiceIDs, SkillNames: skillNames, WebSearchEnabled: webSearchEnabled, Locale: locale, LangfuseTraceparent: langfuse.TraceparentFromContext(ctx), } if agent == nil { return snapshot, "", effectiveTenantID, modelOverride } modelID := modelOverride if modelID == "" { modelID = agent.Config.ModelID } agentTenantID := effectiveTenantID if agentTenantID == 0 { agentTenantID = agent.TenantID } // Marshal/unmarshal gives the snapshot independent backing slices, so a // later agent edit cannot mutate an in-flight message context. if agent.Config.QuestionSuggestions != nil { if encoded, err := json.Marshal(agent.Config.QuestionSuggestions); err == nil { var suggestions types.QuestionSuggestionConfig if json.Unmarshal(encoded, &suggestions) == nil { snapshot.QuestionSuggestions = &suggestions } } } hashInput := struct { QuestionSuggestions *types.QuestionSuggestionConfig `json:"question_suggestions,omitempty"` KnowledgeBaseIDs []string `json:"knowledge_base_ids,omitempty"` KnowledgeIDs []string `json:"knowledge_ids,omitempty"` TagIDs []string `json:"tag_ids,omitempty"` TagScopes []types.TagScope `json:"tag_scopes,omitempty"` ModelID string `json:"model_id,omitempty"` }{ QuestionSuggestions: snapshot.QuestionSuggestions, KnowledgeBaseIDs: knowledgeBaseIDs, KnowledgeIDs: knowledgeIDs, TagIDs: tagIDs, TagScopes: snapshot.TagScopes, ModelID: modelID, } if encoded, err := json.Marshal(hashInput); err == nil { hash := sha256.Sum256(encoded) snapshot.AgentConfigHash = fmt.Sprintf("%x", hash[:]) } return snapshot, agent.ID, agentTenantID, modelID } func cloneTagScopes(scopes []types.TagScope) []types.TagScope { if len(scopes) == 0 { return nil } cloned := make([]types.TagScope, 0, len(scopes)) for _, scope := range scopes { if scope.KnowledgeBaseID == "" || len(scope.TagIDs) == 0 { continue } cloned = append(cloned, types.TagScope{ KnowledgeBaseID: scope.KnowledgeBaseID, TagIDs: append([]string(nil), scope.TagIDs...), }) } return cloned } // resolveAgent resolves the custom agent by ID, trying shared agent first, then own agent. // Returns (nil, 0) if agentID is empty or not found. func (h *Handler) resolveAgent( ctx context.Context, c *gin.Context, agentID string, sourceTenantID uint64, ) (*types.CustomAgent, uint64, bool) { if agentID == "" { return nil, 0, false } logger.Infof(ctx, "Resolving agent, agent ID: %s", secutils.SanitizeForLog(agentID)) // Try shared agent first var customAgent *types.CustomAgent var effectiveTenantID uint64 var sharedAgentReadOnly bool userIDVal, _ := c.Get(types.UserIDContextKey.String()) currentTenantID := c.GetUint64(types.TenantIDContextKey.String()) if h.agentShareService != nil && userIDVal != nil && currentTenantID != 0 { callerTenantRole := types.TenantRoleFromContext(ctx) var agent *types.CustomAgent var err error agent, err = h.agentShareService.GetSharedAgentForTenant(ctx, currentTenantID, callerTenantRole, agentID, sourceTenantID) if err == nil && agent != nil { effectiveTenantID = agent.TenantID customAgent = agent sharedAgentReadOnly = true logger.Infof(ctx, "Using shared agent: ID=%s, Name=%s, effectiveTenantID=%d (retrieval scope)", customAgent.ID, customAgent.Name, effectiveTenantID) } } // Fall back to an own agent only when no source workspace was requested. // A rejected shared selector must not silently run a same-ID local builtin. if customAgent == nil && sourceTenantID == 0 { agent, err := h.customAgentService.GetAgentByID(ctx, 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(agentID), err) } } else if customAgent != nil { 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) } return customAgent, effectiveTenantID, sharedAgentReadOnly } // mergeKnowledgeTargets merges request KB/knowledge IDs with @mentioned items into deduplicated slices. func mergeKnowledgeTargets(requestKBIDs []string, requestKnowledgeIDs []string, mentionedItems []MentionedItemRequest) (kbIDs []string, knowledgeIDs []string) { kbIDSet := make(map[string]bool) kbIDs = make([]string, 0, len(requestKBIDs)+len(mentionedItems)) for _, id := range requestKBIDs { if id != "" && !kbIDSet[id] { kbIDs = append(kbIDs, id) kbIDSet[id] = true } } knowledgeIDSet := make(map[string]bool) knowledgeIDs = make([]string, 0, len(requestKnowledgeIDs)+len(mentionedItems)) for _, id := range requestKnowledgeIDs { if id != "" && !knowledgeIDSet[id] { knowledgeIDs = append(knowledgeIDs, id) knowledgeIDSet[id] = true } } for _, item := range 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 } } } return kbIDs, knowledgeIDs } // 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.userMessageID, reqCtx.userCreatedAt, reqCtx.assistantMessage, ) // 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) } } // The session's sandbox stays bound to the session owner even when the // borrowed tenant above drives everything else, because DeleteSession tears // that sandbox down from a request that only knows the session's tenant. baseCtx = types.WithSandboxTenantID(baseCtx, reqCtx.session.TenantID) baseCtx = context.WithValue(baseCtx, types.SessionTenantIDContextKey, reqCtx.session.TenantID) // An agent that opted out of long-term memory has to be opted out of the // write path too, not just recall. The two run from different contexts: // recall is marked inside the QA services, while extraction, the explicit // "remember this" route and document affinity are all kicked off from // completeAssistantMessage on a context descended from this one. Marking // the root of the async work is what keeps them from disagreeing — an // agent that cannot read the memory must not keep writing to it. if reqCtx.customAgent != nil { baseCtx = types.ApplyAgentMemoryPreference(baseCtx, reqCtx.customAgent.Config.MemoryEnabled) } // 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) // Watch for stop events independently of the client SSE connection so a // user-requested stop reliably cancels generation even when the client // has already disconnected (e.g. API-Key callers that close the stream // before POSTing /stop). The watcher self-terminates on a terminal stream // event, so its lifetime is decoupled from when the QA service call // returns (KnowledgeQA returns immediately while streaming continues in a // background goroutine, whereas AgentQA blocks until done). Use a // connection-independent context derived from baseCtx so it survives the // client disconnect. h.startStopWatcher(logger.CloneContext(baseCtx), reqCtx.sessionID, reqCtx.assistantMessage.ID, eventBus) // Setup stream handler h.setupStreamHandler(asyncCtx, reqCtx.sessionID, reqCtx.assistantMessage.ID, reqCtx.requestID, reqCtx.session.TenantID, reqCtx.receivedAt, 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 "搜索请求" // @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle) // @Success 200 {object} map[string]interface{} "搜索结果" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-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 } // Resolve the storage-reference representation before retrieving, so a typo // or a rejected scope costs nothing. rewriter, err := h.resolveResourceRewriter(c) if err != nil { logger.Warnf(ctx, "Rejected resource URL mode: %v", err) _ = c.Error(err) 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) } } mentionScopes := tagScopesFromMentionedItems(request.MentionedItems) requestTagIDs := dedupRequestStrings(request.TagIDs) if err := validateUnscopedTagIDs(orphanTagIDsForScope(requestTagIDs, mentionScopes), secutils.SanitizeForLogArray(knowledgeBaseIDs)); err != nil { logger.Error(ctx, err.Error()) c.Error(errors.NewBadRequestError(err.Error())) return } tagScopes := mergeTagScopesFromRequestIDs(mentionScopes, requestTagIDs, secutils.SanitizeForLogArray(knowledgeBaseIDs)) if len(knowledgeBaseIDs) == 0 && len(request.KnowledgeIDs) == 0 && len(tagScopes) == 0 { logger.Error(ctx, "No knowledge base IDs, knowledge IDs, or tag scopes provided") c.Error(errors.NewBadRequestError("At least one knowledge_base_id, knowledge_base_ids, knowledge_ids, or scoped tag must be provided")) return } if err := types.AuthorizeTenantAPIKeyKnowledgeTargets(ctx, knowledgeBaseIDs, request.KnowledgeIDs); err != nil { c.Error(err) return } logger.Infof( ctx, "Knowledge search request, knowledge base IDs: %v, knowledge IDs: %v, tag scopes: %d, query: %s", secutils.SanitizeForLogArray(knowledgeBaseIDs), secutils.SanitizeForLogArray(request.KnowledgeIDs), len(tagScopes), secutils.SanitizeForLog(request.Query), ) // Directly call knowledge retrieval service without LLM summarization searchResults, err := h.sessionService.SearchKnowledge(ctx, knowledgeBaseIDs, request.KnowledgeIDs, tagScopes, 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": rewriter.CopyReferences(ctx, 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 "问答请求" // @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle) // @Success 200 {object} map[string]interface{} "问答结果(SSE流)" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /knowledge-chat/{session_id} [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.executeQA(reqCtx, qaModeNormal, !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 "问答请求" // @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle) // @Success 200 {object} map[string]interface{} "问答结果(SSE流)" // @Failure 400 {object} errors.AppError "请求参数错误" // @Security Bearer // @Security ApiKeyAuth // @Router /agent-chat/{session_id} [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) } // Sanity gate: agent mode requires a resolved CustomAgent. If we got // here with agent_enabled=true but agent_id missing/unresolvable, the // AgentQA service will fail deep inside the async goroutine with a // generic "custom agent configuration is required" error and the user // just sees a broken stream. Reject early with a clear 400 so the // frontend can recover (e.g. fall back to quick-answer). Most likely // cause is a stale localStorage settings blob where selectedAgentId // got blanked but isAgentEnabled stayed true — usually after a // cross-tenant switch where the previously selected agent is no // longer visible. if agentModeEnabled && reqCtx.customAgent == nil { logger.Warnf(reqCtx.ctx, "Agent mode requested without a resolvable agent_id, rejecting; session=%s, request.AgentID=%q", reqCtx.sessionID, secutils.SanitizeForLog(request.AgentID)) c.Error(errors.NewBadRequestError( "agent_id is required when agent mode is enabled")) return } // Route to appropriate handler based on agent mode if agentModeEnabled { h.executeQA(reqCtx, qaModeAgent, true) } else { logger.Infof(reqCtx.ctx, "Agent mode disabled, delegating to normal mode for session: %s", reqCtx.sessionID) h.executeQA(reqCtx, qaModeNormal, !request.DisableTitle) } } // qaMode determines which QA execution path to use. type qaMode int const ( qaModeNormal qaMode = iota // KnowledgeQA pipeline (RAG / pure chat) qaModeAgent // Agent engine with tool calling ) // executeQA is the unified execution flow for both KnowledgeQA and AgentQA modes. // It handles message creation, SSE setup, VLM analysis, service invocation, and error handling. func (h *Handler) executeQA(reqCtx *qaRequestContext, mode qaMode, generateTitle bool) { ctx := reqCtx.ctx sessionID := reqCtx.sessionID // Persist the input-bar state used for this request so reopening the // session can rehydrate agent / model / KB / web-search / MCP selections. // This is a pure UI memo (no behavioural effect) and runs in a goroutine // to avoid adding a DB round-trip to TTFB. Use WithoutCancel so a fast // client disconnect doesn't drop the write. go h.persistLastRequestState(ctx, reqCtx, mode) // Agent mode: emit agent query event before message creation if mode == qaModeAgent { 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. Include pre-uploaded document metadata so history // reload shows the attachments even though their content is selected later. userMessageAttachments := reqCtx.attachments if len(reqCtx.attachmentMetas) > 0 { userMessageAttachments = append(append(types.MessageAttachments{}, reqCtx.attachments...), reqCtx.attachmentMetas...) } userMsg, err := h.createUserMessage(ctx, sessionID, reqCtx.query, reqCtx.requestID, reqCtx.mentionedItems, convertImageAttachments(reqCtx.images), userMessageAttachments, reqCtx.channel, reqCtx.suggestionAttribution) if err != nil { reqCtx.c.Error(errors.NewInternalServerError(err.Error())) return } reqCtx.userMessageID = userMsg.ID reqCtx.userCreatedAt = userMsg.CreatedAt if err := h.bindUploadedMessageResources(ctx, userMsg); err != nil { reqCtx.c.Error(errors.NewInternalServerError("Failed to attach uploaded files to message")) return } // Create assistant message assistantMessagePtr, err := h.createAssistantMessage(ctx, reqCtx.assistantMessage) if err != nil { reqCtx.c.Error(errors.NewInternalServerError(err.Error())) return } reqCtx.assistantMessage = assistantMessagePtr if mode == qaModeNormal { logger.Infof(ctx, "Using knowledge bases: %v", reqCtx.knowledgeBaseIDs) } else { logger.Infof(ctx, "Calling agent QA service, session ID: %s", sessionID) } // Setup SSE stream streamCtx := h.setupSSEStream(reqCtx, generateTitle) // Normal mode: register completion handler on EventAgentFinalAnswer // (Agent mode handles completion in the defer block instead) if mode == qaModeNormal { var completionHandled bool // Persist the pipeline's retrieval/attachment stages so a reloaded // conversation redraws the timeline it showed while streaming, including // turns that searched and cited nothing. registerQuickAnswerTimelineRecorder(streamCtx.eventBus, streamCtx.assistantMessage) // Persist reasoning_content into agent_steps so historical reload can // reconstruct the thinking card (same shape as Agent-mode steps). // Accumulate on assistantMessage directly so user-initiated stop also // keeps whatever reasoning had streamed before the cancel. streamCtx.eventBus.On(event.EventAgentThought, func(ctx context.Context, evt event.Event) error { data, ok := evt.Data.(event.AgentThoughtData) if !ok || data.Content == "" { return nil } appendQuickAnswerReasoning(streamCtx.assistantMessage, data.Content) return nil }) 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 { if completionHandled { return nil } completionHandled = true logger.Infof(streamCtx.asyncCtx, "Knowledge QA service completed for session: %s", sessionID) updateCtx := context.WithValue(streamCtx.asyncCtx, types.TenantIDContextKey, reqCtx.session.TenantID) h.completeAssistantMessage(updateCtx, streamCtx.assistantMessage, reqCtx.query, reqCtx.userMessageID) streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{ Type: event.EventAgentComplete, SessionID: sessionID, Data: event.AgentCompleteData{FinalAnswer: streamCtx.assistantMessage.Content}, }) } return nil }) } // Execute QA asynchronously go func() { defer func() { if r := recover(); r != nil { buf := make([]byte, 10240) runtime.Stack(buf, true) stageName := "Knowledge QA" if mode == qaModeAgent { stageName = "Agent QA" } logger.ErrorWithFields(streamCtx.asyncCtx, errors.NewInternalServerError(fmt.Sprintf("%s service panicked: %v\n%s", stageName, r, string(buf))), map[string]interface{}{"session_id": sessionID}) } // Agent mode: complete the assistant message in defer (normal mode does it via event handler) if mode == qaModeAgent { // Use WithoutCancel so a user-triggered stop (which cancels // asyncCtx) doesn't also cancel the GORM UPDATE that persists // AgentSteps/Content. Without this, cancelled-ctx makes // GORM skip the write and the agent's intermediate steps // (thinking / tool_call history) are lost on page refresh. updateCtx := context.WithValue( context.WithoutCancel(streamCtx.asyncCtx), types.TenantIDContextKey, reqCtx.session.TenantID, ) h.completeAssistantMessage(updateCtx, streamCtx.assistantMessage, reqCtx.query, reqCtx.userMessageID) logger.Infof(streamCtx.asyncCtx, "Agent QA service completed for session: %s", sessionID) } }() // Resolve pre-uploaded attachments (may still be parsing): waits with a // timeline step so the send is not blocked, then injects content/images. h.resolveTemporaryAttachments(streamCtx, reqCtx) // Run VLM image analysis if applicable h.runVLMAnalysisIfNeeded(streamCtx, reqCtx, mode) // Build QA request and invoke the appropriate service qaReq := reqCtx.buildQARequest() var serviceErr error var stageName string if mode == qaModeNormal { stageName = "knowledge_qa_execution" serviceErr = h.sessionService.KnowledgeQA(streamCtx.asyncCtx, qaReq, streamCtx.eventBus) } else { stageName = "agent_execution" serviceErr = h.sessionService.AgentQA(streamCtx.asyncCtx, qaReq, streamCtx.eventBus) } if serviceErr != nil { // A user-requested stop cancels asyncCtx, which surfaces here as a // context cancellation. That is an expected outcome, not a failure: // the stop event already notifies the client, so don't emit a // spurious error event (which would otherwise show an error toast). if streamCtx.asyncCtx.Err() != nil { logger.Infof(streamCtx.asyncCtx, "QA cancelled by user stop for session: %s", sessionID) } else { logger.ErrorWithFields(streamCtx.asyncCtx, serviceErr, nil) streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{ Type: event.EventError, SessionID: sessionID, Data: event.ErrorData{ Error: serviceErr.Error(), Stage: stageName, 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, reqCtx.resourceRewriter) } // runVLMAnalysisIfNeeded runs VLM image analysis within the async goroutine, // emitting tool_call/tool_result events so the user can see progress. // For normal mode, VLM only runs on the pure-chat path (no KB, no web search); // RAG paths defer VLM to the pipeline rewrite step. // For agent mode, VLM always runs when images and a VLM model are present. func (h *Handler) runVLMAnalysisIfNeeded(streamCtx *sseStreamContext, reqCtx *qaRequestContext, mode qaMode) { if len(reqCtx.images) == 0 || reqCtx.customAgent == nil || reqCtx.customAgent.Config.VLMModelID == "" { return } sessionID := reqCtx.sessionID // In normal mode, only run VLM for pure-chat path if mode == qaModeNormal { hasRequestKBs := len(reqCtx.knowledgeBaseIDs) > 0 || len(reqCtx.knowledgeIDs) > 0 agentWillResolveKBs := false if !hasRequestKBs && reqCtx.customAgent != nil && !reqCtx.customAgent.Config.RetrieveKBOnlyWhenMentioned { switch reqCtx.customAgent.Config.KBSelectionMode { case "all": agentWillResolveKBs = true case "selected", "": agentWillResolveKBs = len(reqCtx.customAgent.Config.KnowledgeBases) > 0 case "none": agentWillResolveKBs = false default: agentWillResolveKBs = len(reqCtx.customAgent.Config.KnowledgeBases) > 0 } } if hasRequestKBs || agentWillResolveKBs || reqCtx.webSearchEnabled { return // VLM will be handled by the pipeline rewrite step } } // Emit VLM tool call/result events toolCallID := uuid.New().String() iteration := 0 // agent mode uses iteration field streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{ Type: event.EventAgentToolCall, SessionID: sessionID, Data: event.AgentToolCallData{ ToolCallID: toolCallID, ToolName: "image_analysis", Iteration: iteration, }, }) vlmStart := time.Now() h.analyzeImageAttachments(streamCtx.asyncCtx, reqCtx.images, reqCtx.customAgent.Config.VLMModelID, reqCtx.query) outputMsg := "已分析图片内容" if mode == qaModeAgent { outputMsg = "已查看图片内容" } streamCtx.eventBus.Emit(streamCtx.asyncCtx, event.Event{ Type: event.EventAgentToolResult, SessionID: sessionID, Data: event.AgentToolResultData{ ToolCallID: toolCallID, ToolName: "image_analysis", Output: outputMsg, Success: true, Duration: time.Since(vlmStart).Milliseconds(), Iteration: iteration, }, }) } // defaultAttachmentParseWaitTimeout bounds how long a QA turn waits for // still-parsing attachments before proceeding with only the finished ones. // Large or scanned documents can exceed this; raise it via // WEKNORA_CHAT_ATTACHMENT_WAIT_TIMEOUT_SEC when needed. const defaultAttachmentParseWaitTimeout = 60 * time.Second // attachmentParseWaitTimeout returns the configured wait timeout, honoring the // WEKNORA_CHAT_ATTACHMENT_WAIT_TIMEOUT_SEC override (in seconds) and falling // back to the default when unset or invalid. func attachmentParseWaitTimeout() time.Duration { if raw := strings.TrimSpace(os.Getenv("WEKNORA_CHAT_ATTACHMENT_WAIT_TIMEOUT_SEC")); raw != "" { if secs, err := strconv.Atoi(raw); err == nil && secs > 0 { return time.Duration(secs) * time.Second } } return defaultAttachmentParseWaitTimeout } // resolveTemporaryAttachments selects prompt content for pre-uploaded documents // after the SSE stream is live. When any attachment is still parsing it emits a // "attachment_parsing" timeline step and waits (bounded); unfinished attachments // are skipped rather than blocking or failing the whole turn. func (h *Handler) resolveTemporaryAttachments(streamCtx *sseStreamContext, reqCtx *qaRequestContext) { if len(reqCtx.attachmentIDs) == 0 { return } ctx := streamCtx.asyncCtx sessionID := reqCtx.sessionID // Prefer the session's tenant over gin.Context: this runs in an async // goroutine after the HTTP handler may have returned. tenantID := reqCtx.session.TenantID start := time.Now() var toolCallID string if h.hasPendingAttachments(ctx, tenantID, sessionID, reqCtx.attachmentIDs) { toolCallID = uuid.New().String() streamCtx.eventBus.Emit(ctx, event.Event{ Type: event.EventAgentToolCall, SessionID: sessionID, Data: event.AgentToolCallData{ ToolCallID: toolCallID, ToolName: "attachment_parsing", Iteration: 0, }, }) waitTimeout := attachmentParseWaitTimeout() if reqCtx.customAgent != nil && reqCtx.customAgent.Config.AttachmentParseWaitTimeoutSec > 0 { waitTimeout = time.Duration(reqCtx.customAgent.Config.AttachmentParseWaitTimeoutSec) * time.Second } h.waitForAttachments(ctx, tenantID, sessionID, reqCtx.attachmentIDs, waitTimeout) } readyIDs, skipped := h.partitionReadyAttachments(ctx, tenantID, sessionID, reqCtx.attachmentIDs) var temporaryResult *types.TemporaryDocumentPromptResult var resolveErr error if len(readyIDs) > 0 { temporaryResult, resolveErr = h.temporaryDocuments.ResolveForPrompt(ctx, tenantID, sessionID, readyIDs, reqCtx.query) } if toolCallID != "" { output := fmt.Sprintf("已解析 %d 个附件", len(readyIDs)) if skipped > 0 { output += fmt.Sprintf(",%d 个未完成已跳过", skipped) } success := resolveErr == nil if resolveErr != nil { output = fmt.Sprintf("附件解析失败: %v", resolveErr) } streamCtx.eventBus.Emit(ctx, event.Event{ Type: event.EventAgentToolResult, SessionID: sessionID, Data: event.AgentToolResultData{ ToolCallID: toolCallID, ToolName: "attachment_parsing", Output: output, Success: success, Duration: time.Since(start).Milliseconds(), Iteration: 0, Data: map[string]interface{}{ "display_type": "attachment_parsing", "parsed_count": len(readyIDs), "skipped_count": skipped, }, }, }) } if resolveErr != nil || temporaryResult == nil { if resolveErr != nil { logger.Warnf(ctx, "temporary attachment resolution failed for session %s: %v", sessionID, resolveErr) } return } attachments := temporaryResult.Attachments if reqCtx.customAgent != nil && len(reqCtx.customAgent.Config.SupportedFileTypes) > 0 { filtered := attachments[:0] for _, att := range attachments { ext := strings.TrimPrefix(strings.ToLower(att.FileType), ".") if containsFileType(reqCtx.customAgent.Config.SupportedFileTypes, ext) { filtered = append(filtered, att) } } attachments = filtered } reqCtx.attachments = append(reqCtx.attachments, attachments...) // Persist the freshly selected content back onto the stored user message. // The message was created with metadata-only attachment entries (content is // selected here, after the SSE stream is live), so without this write a // later Agent-mode turn rebuilds history from the Attachments column and // sees empty attachments (see buildUserHistoryMessage in agent_history.go). h.persistResolvedAttachmentContent(ctx, reqCtx, attachments) if reqCtx.customAgent != nil && reqCtx.customAgent.Config.ImageUploadEnabled { for _, imageURL := range temporaryResult.ImageURLs { reqCtx.images = append(reqCtx.images, ImageAttachment{URL: imageURL}) } } } // persistResolvedAttachmentContent writes the parsed content of pre-uploaded // attachments back onto the stored user message so multi-turn history can // replay it. Entries are matched by their temporary-document ID; only those // present on the message are enriched. Failures are logged but never bubble up // — losing the write only degrades follow-up context, it must not fail the turn. func (h *Handler) persistResolvedAttachmentContent( ctx context.Context, reqCtx *qaRequestContext, resolved types.MessageAttachments, ) { if reqCtx.userMessageID == "" || len(resolved) == 0 { return } // Detach from the request/stream lifetime and pin the session tenant so a // user-triggered stop (which cancels asyncCtx) does not drop the write. updateCtx := context.WithValue( context.WithoutCancel(ctx), types.TenantIDContextKey, reqCtx.session.TenantID, ) msg, err := h.messageService.GetMessage(updateCtx, reqCtx.sessionID, reqCtx.userMessageID) if err != nil || msg == nil { logger.Warnf(updateCtx, "persist attachment content: load user message %s failed: %v", reqCtx.userMessageID, err) return } byID := make(map[string]types.MessageAttachment, len(resolved)) for _, att := range resolved { if att.ID != "" { byID[att.ID] = att } } changed := false for i := range msg.Attachments { if msg.Attachments[i].ID == "" { continue } if enriched, ok := byID[msg.Attachments[i].ID]; ok { msg.Attachments[i] = enriched changed = true } } if !changed { return } if err := h.messageService.UpdateMessage(updateCtx, msg); err != nil { logger.Warnf(updateCtx, "persist attachment content: update user message %s failed: %v", reqCtx.userMessageID, err) } } // normalizeTemporaryAttachmentIDs rejects oversized ID lists before any DB // lookup, then returns a deduplicated, order-preserving list of non-empty IDs. func normalizeTemporaryAttachmentIDs(ids []string) ([]string, error) { if len(ids) > types.MaxTemporaryAttachmentsPerMessage { return nil, fmt.Errorf("a message can use at most %d attachments", types.MaxTemporaryAttachmentsPerMessage) } out := make([]string, 0, len(ids)) seen := make(map[string]struct{}, len(ids)) for _, id := range ids { id = strings.TrimSpace(id) if id == "" { continue } if _, ok := seen[id]; ok { continue } seen[id] = struct{}{} out = append(out, id) } return out, nil } // hasPendingAttachments reports whether any of the given documents is still // uploading or being parsed. func (h *Handler) hasPendingAttachments(ctx context.Context, tenantID uint64, sessionID string, ids []string) bool { for _, id := range ids { doc, err := h.temporaryDocuments.Get(ctx, tenantID, sessionID, id) if err != nil || doc == nil { continue } if doc.Status == types.TemporaryDocumentStatusUploaded || doc.Status == types.TemporaryDocumentStatusProcessing { return true } } return false } // waitForAttachments polls until no attachment is pending or the timeout / ctx // cancellation fires. func (h *Handler) waitForAttachments(ctx context.Context, tenantID uint64, sessionID string, ids []string, timeout time.Duration) { deadline := time.Now().Add(timeout) ticker := time.NewTicker(500 * time.Millisecond) defer ticker.Stop() for { if !h.hasPendingAttachments(ctx, tenantID, sessionID, ids) || time.Now().After(deadline) { return } select { case <-ctx.Done(): return case <-ticker.C: } } } // partitionReadyAttachments splits the ids into ready ones and a count of those // skipped (missing, failed, or still parsing after the wait). func (h *Handler) partitionReadyAttachments(ctx context.Context, tenantID uint64, sessionID string, ids []string) (ready []string, skipped int) { for _, id := range ids { doc, err := h.temporaryDocuments.Get(ctx, tenantID, sessionID, id) if err != nil || doc == nil || doc.Status != types.TemporaryDocumentStatusReady { skipped++ continue } ready = append(ready, id) } return ready, skipped } // persistLastRequestState records the input-bar state the user just sent so // that reopening this session restores agent/model/KB/web-search/MCP picks. // Pure UI memo — failures are logged but never bubble up; the caller runs // this in a goroutine and is safe to discard the returned context. func (h *Handler) persistLastRequestState(parentCtx context.Context, reqCtx *qaRequestContext, mode qaMode) { // Detach from the HTTP request lifetime: this write must survive both // SSE disconnects and the parent gin context being released after the // handler returns. ctx := logger.CloneContext(context.WithoutCancel(parentCtx)) agentEnabled := reqCtx.reqAgentEnabled // Mirror the resolution rule used in AgentQA: a resolved custom agent's // agent_mode wins over the request flag. For KnowledgeQA the request // itself carries agent_enabled=false, so this collapses correctly. if mode == qaModeAgent && reqCtx.customAgent != nil { agentEnabled = reqCtx.customAgent.IsAgentMode() } state := &types.SessionLastRequestState{ AgentID: reqCtx.reqAgentID, AgentEnabled: agentEnabled, ModelID: reqCtx.summaryModelID, KnowledgeBaseIDs: reqCtx.knowledgeBaseIDs, KnowledgeIDs: reqCtx.knowledgeIDs, TagIDs: reqCtx.tagIDs, MCPServiceIDs: reqCtx.mcpServiceIDs, SkillNames: reqCtx.skillNames, MentionedItems: reqCtx.mentionedItems, WebSearchEnabled: reqCtx.webSearchEnabled, } if err := h.sessionService.UpdateSessionLastRequestState(ctx, reqCtx.sessionID, state); err != nil { logger.Warnf(ctx, "persist last_request_state failed for session %s: %v", reqCtx.sessionID, err) } } // appendQuickAnswerReasoning accumulates streamed reasoning_content from // KnowledgeQA (fast answer) into a single AgentStep for history replay. func appendQuickAnswerReasoning(msg *types.Message, content string) { if content == "" { return } ensureQuickAnswerStep(msg).ReasoningContent += content } // 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, userMessageID 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) if userQuery != "" && h.suggestionService != nil { go func() { if _, err := h.suggestionService.EnsureFollowUps( bgCtx, assistantMessage.SessionID, assistantMessage.ID, false, ); err != nil { logger.Warnf(bgCtx, "follow-up suggestion generation failed for message %s: %v", assistantMessage.ID, err) } }() } if userQuery != "" { go h.recordTurnMemory(bgCtx, assistantMessage, userQuery, userMessageID) } } // recordTurnMemory runs the long-term memory write path for a finished turn. // // This is the single place a conversation can produce memory, and it sits at // the point where both the RAG and the Agent path converge, so neither mode // can silently miss it. A stopped conversation arrives with an empty query and // is skipped by the caller. func (h *Handler) recordTurnMemory( ctx context.Context, assistantMessage *types.Message, userQuery, userMessageID string, ) { if h.memoryService == nil { return } // An explicit "remember ..." directive is stored verbatim and immediately, // with no model in the loop. This is what makes the default explicit_only // mode useful rather than merely safe. if statement, ok := types.DetectExplicitMemory(userQuery); ok { if _, err := h.memoryService.Remember(ctx, types.MemoryItem{ Kind: types.MemoryKindFact, Content: statement, Importance: 4, Origin: types.MemoryOriginExplicit, SourceSessionID: assistantMessage.SessionID, // Attribute to the user's own message, not the answer. Background // distillation reads that same message, so the two paths must // agree on provenance or a memory deleted from one can be // re-derived by the other. SourceMessageID: userMessageID, }); err != nil { logger.Warnf(ctx, "memory: explicit remember failed for message %s: %v", assistantMessage.ID, err) } } h.recordAnswerSources(ctx, assistantMessage) h.memoryService.ScheduleExtraction(ctx, assistantMessage.SessionID, assistantMessage.ID, assistantMessage.ModelID) } // recordAnswerSources notes which documents this answer drew on, so the // reranker can prefer the material this person keeps working from. // // The references attached to an answer are a weaker signal than an explicit // thumbs-up: they say the retriever kept picking a document, not that the user // found it useful. They are, however, the only per-person retrieval signal // available without asking for anything, and the boost they earn is capped // accordingly. func (h *Handler) recordAnswerSources(ctx context.Context, assistantMessage *types.Message) { if len(assistantMessage.KnowledgeReferences) == 0 { return } seen := make(map[string]struct{}, len(assistantMessage.KnowledgeReferences)) refs := make([]types.MemoryDocAffinity, 0, len(assistantMessage.KnowledgeReferences)) for _, ref := range assistantMessage.KnowledgeReferences { if ref.KnowledgeID == "" { continue } if _, dup := seen[ref.KnowledgeID]; dup { continue } seen[ref.KnowledgeID] = struct{}{} refs = append(refs, types.MemoryDocAffinity{ KnowledgeID: ref.KnowledgeID, KnowledgeBaseID: ref.KnowledgeBaseID, Title: ref.KnowledgeTitle, }) } h.memoryService.RecordAnswerSources(ctx, refs) }