Files
yawan f7fc992f57
Anydoc / anydoc parser engine (push) Canceled after 0s
App / Format, vet, test, and build (push) Canceled after 0s
CLI / test (macos-latest / go 1.26) (push) Canceled after 0s
CLI / test (ubuntu-latest / go 1.26) (push) Canceled after 0s
CLI / test (windows-latest / go 1.26) (push) Canceled after 0s
Build and Push Docker Image / build-ui (push) Canceled after 0s
Build and Push Docker Image / build-docreader (push) Canceled after 0s
Build and Push Docker Image / build-sandbox (push) Canceled after 0s
Build and Push Docker Image / build-app (amd64, linux/amd64, ubuntu-latest) (push) Canceled after 0s
Build and Push Docker Image / build-app (arm64, linux/arm64, ubuntu-24.04-arm) (push) Canceled after 0s
DocReader / Python tests and Go client tests (push) Canceled after 0s
Build & Publish (dsh-weknora) / Typecheck, test, pack (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (pinned) (push) Canceled after 0s
Build & Publish (dsh-weknora) / End-to-end inside dsh (latest, advisory) (push) Canceled after 0s
Frontend / Test, type-check, and build (push) Canceled after 0s
Go Lint Cache / warm lint cache (.) (push) Canceled after 0s
Go Lint Cache / warm lint cache (cli) (push) Canceled after 0s
Go Lint Cache / warm lint cache (client) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.10) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.11) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.12) (push) Canceled after 0s
Build & Publish (mcp-server) / Test (Python 3.13) (push) Canceled after 0s
Build and Push Docker Image / merge (push) Canceled after 0s
Build & Publish (dsh-weknora) / Publish to npm (push) Canceled after 0s
Build & Publish (mcp-server) / Build sdist + wheel (push) Canceled after 0s
Build & Publish (mcp-server) / Publish to PyPI (push) Canceled after 0s
WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
2026-09-22 16:19:06 +08:00

1527 lines
59 KiB
Go
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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)
}