WeKnora deployment source with the reviewed wiki identity, summary and recovery fixes
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

This commit is contained in:
yawan
2026-09-22 16:19:06 +08:00
commit f7fc992f57
3929 changed files with 1006607 additions and 0 deletions
@@ -0,0 +1,845 @@
package session
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/agent/skills"
agenttools "github.com/Tencent/WeKnora/internal/agent/tools"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// AgentStreamHandler handles agent events for SSE streaming
// It uses a dedicated EventBus per request to avoid SessionID filtering
// Events are appended to StreamManager without accumulation
type AgentStreamHandler struct {
ctx context.Context
sessionID string
tenantID uint64 // Tenant that owns this session; used when persisting skill artifacts.
assistantMessageID string
requestID string
receivedAt time.Time // Handler entry timestamp, used for TTFB logging
ttfbLogged bool // Guards one-shot TTFB log on first answer chunk
assistantMessage *types.Message
streamManager interfaces.StreamManager
eventBus *event.EventBus
// artifactCollector drains skill-generated files from the session
// sandbox after the agent completes. Nil when the sandbox backend
// doesn't support artifact collection or WeKnora was built without it.
artifactCollector *service.ArtifactCollector
// State tracking
knowledgeRefs []*types.SearchResult
finalAnswer string
answerSegments []*answerSegment // Per-answer-event-ID accumulation, so superseded preambles can be dropped
eventStartTimes map[string]time.Time // Track start time for duration calculation
mu sync.Mutex
}
// answerSegment accumulates the streamed content of a single final-answer event
// ID. A non-terminal round may stream a preamble ("let me search…") under its
// own answer ID and then be marked superseded once the round turns out to call
// tools; tracking segments separately lets us exclude that preamble from the
// persisted assistant message instead of leaking it into the final answer.
type answerSegment struct {
id string
content string
superseded bool
}
// findAnswerSegment returns the segment for an answer event ID, or nil.
// Callers must hold h.mu.
func (h *AgentStreamHandler) findAnswerSegment(id string) *answerSegment {
for _, seg := range h.answerSegments {
if seg.id == id {
return seg
}
}
return nil
}
// composeFinalAnswer rebuilds the persisted answer from all non-superseded
// segments in arrival order. Callers must hold h.mu.
func (h *AgentStreamHandler) composeFinalAnswer() string {
var b strings.Builder
for _, seg := range h.answerSegments {
if !seg.superseded {
b.WriteString(seg.content)
}
}
return b.String()
}
// NewAgentStreamHandler creates a new handler for agent SSE streaming
func NewAgentStreamHandler(
ctx context.Context,
sessionID, assistantMessageID, requestID string,
tenantID uint64,
receivedAt time.Time,
assistantMessage *types.Message,
streamManager interfaces.StreamManager,
eventBus *event.EventBus,
artifactCollector *service.ArtifactCollector,
) *AgentStreamHandler {
return &AgentStreamHandler{
ctx: ctx,
sessionID: sessionID,
tenantID: tenantID,
assistantMessageID: assistantMessageID,
requestID: requestID,
receivedAt: receivedAt,
assistantMessage: assistantMessage,
streamManager: streamManager,
eventBus: eventBus,
artifactCollector: artifactCollector,
knowledgeRefs: make([]*types.SearchResult, 0),
eventStartTimes: make(map[string]time.Time),
}
}
// Subscribe subscribes to all agent streaming events on the dedicated EventBus
// No SessionID filtering needed since we have a dedicated EventBus per request
func (h *AgentStreamHandler) Subscribe() {
// Subscribe to all agent streaming events on the dedicated EventBus
h.eventBus.On(event.EventAgentThought, h.handleThought)
h.eventBus.On(event.EventAgentToolCall, h.handleToolCall)
h.eventBus.On(event.EventAgentToolResult, h.handleToolResult)
h.eventBus.On(event.EventAgentReferences, h.handleReferences)
h.eventBus.On(event.EventMemoryRecalled, h.handleMemoryRecalled)
h.eventBus.On(event.EventContextCompacted, h.handleContextCompacted)
h.eventBus.On(event.EventAgentFinalAnswer, h.handleFinalAnswer)
h.eventBus.On(event.EventAgentReflection, h.handleReflection)
h.eventBus.On(event.EventError, h.handleError)
h.eventBus.On(event.EventSessionTitle, h.handleSessionTitle)
h.eventBus.On(event.EventAgentComplete, h.handleComplete)
h.eventBus.On(event.EventToolApprovalRequired, h.handleToolApprovalRequired)
h.eventBus.On(event.EventToolApprovalResolved, h.handleToolApprovalResolved)
h.eventBus.On(event.EventMCPOAuthRequired, h.handleMCPOAuthRequired)
h.eventBus.On(event.EventMCPOAuthResolved, h.handleMCPOAuthResolved)
}
// handleThought handles agent thought events
func (h *AgentStreamHandler) handleThought(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentThoughtData)
if !ok {
return nil
}
h.mu.Lock()
// Track start time on first chunk
if _, exists := h.eventStartTimes[evt.ID]; !exists {
h.eventStartTimes[evt.ID] = time.Now()
}
// Calculate duration if done
var metadata map[string]interface{}
if data.Done {
startTime := h.eventStartTimes[evt.ID]
duration := time.Since(startTime)
metadata = map[string]interface{}{
"event_id": evt.ID,
"duration_ms": duration.Milliseconds(),
"completed_at": time.Now().Unix(),
}
delete(h.eventStartTimes, evt.ID)
} else {
metadata = map[string]interface{}{
"event_id": evt.ID,
}
}
h.mu.Unlock()
// Append this chunk to stream (no accumulation - frontend will accumulate)
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeThinking,
Content: data.Content, // Just this chunk
Done: data.Done,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append thought event to stream failed", "error", err)
}
return nil
}
// handleToolCall handles tool call events
func (h *AgentStreamHandler) handleToolCall(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentToolCallData)
if !ok {
return nil
}
h.mu.Lock()
_, first := h.eventStartTimes[data.ToolCallID]
if !first {
h.eventStartTimes[data.ToolCallID] = time.Now()
// Any answer text streamed before this tool call was a non-terminal round's
// preamble, not the final answer (the agent only ends by stopping naturally
// with plain text and no tool calls). Drop those segments from the persisted
// answer so the preamble never leaks into Message.Content.
supersededAny := false
for _, seg := range h.answerSegments {
if !seg.superseded && seg.content != "" {
seg.superseded = true
supersededAny = true
}
}
if supersededAny {
h.finalAnswer = h.composeFinalAnswer()
}
}
h.mu.Unlock()
metadata := map[string]interface{}{
"tool_name": data.ToolName,
"arguments": agenttools.SanitizeSandboxFileCallArgs(data.ToolName, data.Arguments),
"tool_call_id": data.ToolCallID,
}
// Append event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeToolCall,
Content: fmt.Sprintf("Calling tool: %s", data.ToolName),
Done: false,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool call event to stream failed", "error", err)
}
return nil
}
// handleToolResult handles tool result events
func (h *AgentStreamHandler) handleToolResult(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentToolResultData)
if !ok {
return nil
}
h.mu.Lock()
// Calculate duration from start time if available, otherwise use provided duration
var durationMs int64
if startTime, exists := h.eventStartTimes[data.ToolCallID]; exists {
durationMs = time.Since(startTime).Milliseconds()
delete(h.eventStartTimes, data.ToolCallID)
} else if data.Duration > 0 {
// Fallback to provided duration if start time not tracked
durationMs = data.Duration
}
h.mu.Unlock()
// Send SSE response (both success and failure)
responseType := types.ResponseTypeToolResult
content := agenttools.StreamContentForToolResult(data.ToolName, data.Success, data.Error, data.Data)
if !data.Success {
responseType = types.ResponseTypeError
if content == "" && data.Error != "" {
content = data.Error
}
}
// Build metadata including tool result data for rich frontend rendering
metadata := map[string]interface{}{
"tool_name": data.ToolName,
"success": data.Success,
"error": data.Error,
"duration_ms": durationMs,
"tool_call_id": data.ToolCallID,
}
clientData := agenttools.SanitizeToolResultForClient(data.ToolName, &types.ToolResult{
Success: data.Success,
Output: data.Output,
Error: data.Error,
Data: data.Data,
})
for k, v := range clientData {
metadata[k] = v
}
// Append event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: responseType,
Content: content,
Done: false,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool result event to stream failed", "error", err)
}
return nil
}
func toolApprovalDataToMap(v interface{}) map[string]interface{} {
b, err := json.Marshal(v)
if err != nil {
return map[string]interface{}{}
}
var m map[string]interface{}
if err := json.Unmarshal(b, &m); err != nil {
return map[string]interface{}{}
}
return m
}
// handleToolApprovalRequired persists MCP tool human-approval prompts for SSE / replay (issue #1173).
func (h *AgentStreamHandler) handleToolApprovalRequired(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ToolApprovalRequiredData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeToolApprovalRequired,
Content: "MCP tool requires human approval",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool approval required event failed", "error", err)
}
return nil
}
// handleToolApprovalResolved persists the outcome of a tool approval (issue #1173).
func (h *AgentStreamHandler) handleToolApprovalResolved(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ToolApprovalResolvedData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeToolApprovalResolved,
Content: "MCP tool approval resolved",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append tool approval resolved event failed", "error", err)
}
return nil
}
// handleMCPOAuthRequired forwards an in-conversation "authorize this MCP
// service" prompt to the SSE stream so the UI can render an Authorize card.
func (h *AgentStreamHandler) handleMCPOAuthRequired(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MCPOAuthRequiredData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMCPOAuthRequired,
Content: "MCP service requires OAuth authorization",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append mcp oauth required event failed", "error", err)
}
return nil
}
// handleMCPOAuthResolved forwards the outcome of an in-conversation OAuth prompt.
func (h *AgentStreamHandler) handleMCPOAuthResolved(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MCPOAuthResolvedData)
if !ok {
return nil
}
meta := toolApprovalDataToMap(data)
meta["pending_id"] = data.PendingID
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMCPOAuthResolved,
Content: "MCP OAuth authorization resolved",
Done: true,
Timestamp: time.Now(),
Data: meta,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append mcp oauth resolved event failed", "error", err)
}
return nil
}
// handleReferences handles knowledge references events
func (h *AgentStreamHandler) handleReferences(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentReferencesData)
if !ok {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
// Extract knowledge references
// Try to cast directly to []*types.SearchResult first
if searchResults, ok := data.References.([]*types.SearchResult); ok {
h.knowledgeRefs = append(h.knowledgeRefs, searchResults...)
} else if refs, ok := data.References.([]interface{}); ok {
// Fallback: convert from []interface{}
for _, ref := range refs {
if sr, ok := ref.(*types.SearchResult); ok {
h.knowledgeRefs = append(h.knowledgeRefs, sr)
} else if refMap, ok := ref.(map[string]interface{}); ok {
// Parse from map if needed
h.knowledgeRefs = append(h.knowledgeRefs, searchResultFromMap(refMap))
}
}
}
// Update assistant message references
h.assistantMessage.KnowledgeReferences = h.knowledgeRefs
// Append references event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeReferences,
Content: "",
Done: false,
Timestamp: time.Now(),
Data: map[string]interface{}{
"references": types.References(h.knowledgeRefs),
},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append references event to stream failed", "error", err)
}
return nil
}
// handleMemoryRecalled records the long-term memories injected into this turn.
// The list is both persisted on the assistant message and streamed, so the
// panel is present live and after a reload.
func (h *AgentStreamHandler) handleMemoryRecalled(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.MemoryRecalledData)
if !ok {
return nil
}
used, ok := data.Memories.(types.UsedMemories)
if !ok || len(used) == 0 {
return nil
}
h.mu.Lock()
h.assistantMessage.UsedMemories = used
h.mu.Unlock()
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeMemoryRecalled,
Done: false,
Timestamp: time.Now(),
Data: map[string]interface{}{"memories": used},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append memory recalled event to stream failed", "error", err)
}
return nil
}
// handleContextCompacted forwards a compaction to the UI.
//
// The summary itself is carried so the user can expand it and see exactly what
// the agent kept, which is the only way to tell an agent that forgot something
// from an agent that never had it.
func (h *AgentStreamHandler) handleContextCompacted(_ context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ContextCompactedData)
if !ok {
return nil
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeContextCompacted,
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"reason": data.Reason,
"round": data.Round,
"tokens_before": data.TokensBefore,
"tokens_after": data.TokensAfter,
"messages_before": data.MessagesBefore,
"messages_after": data.MessagesAfter,
"summary": data.Summary,
"degraded": data.Degraded,
"split_turn": data.SplitTurn,
},
}); err != nil {
logger.GetLogger(h.ctx).Error("Append context compacted event to stream failed", "error", err)
}
return nil
}
// handleFinalAnswer handles final answer events
func (h *AgentStreamHandler) handleFinalAnswer(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentFinalAnswerData)
if !ok {
return nil
}
h.mu.Lock()
// Track start time on first chunk
if _, exists := h.eventStartTimes[evt.ID]; !exists {
h.eventStartTimes[evt.ID] = time.Now()
}
// Emit a one-shot TTFB log the first time *any* answer chunk reaches
// the stream handler. This lets us compare the backend's "request in →
// first token out" timing against the frontend-observed TTFB and pin
// down where latency lives (network vs server vs LLM).
if !h.ttfbLogged && !h.receivedAt.IsZero() {
h.ttfbLogged = true
ttfb := time.Since(h.receivedAt)
logger.GetLogger(h.ctx).Infof("TTFB:first_answer_chunk request_id=%s, session_id=%s, ttfb_ms=%d",
h.requestID, h.sessionID, ttfb.Milliseconds())
}
// Accumulate final answer locally for assistant message (database). Track
// per event ID so a later supersede can subtract this segment's content.
if data.Content != "" {
seg := h.findAnswerSegment(evt.ID)
if seg == nil {
seg = &answerSegment{id: evt.ID}
h.answerSegments = append(h.answerSegments, seg)
}
seg.content += data.Content
h.finalAnswer = h.composeFinalAnswer()
}
if data.IsFallback {
h.assistantMessage.IsFallback = true
}
// Calculate duration if done
var metadata map[string]interface{}
if data.Done {
startTime := h.eventStartTimes[evt.ID]
duration := time.Since(startTime)
metadata = map[string]interface{}{
"event_id": evt.ID,
"duration_ms": duration.Milliseconds(),
"completed_at": time.Now().Unix(),
}
delete(h.eventStartTimes, evt.ID)
} else {
metadata = map[string]interface{}{
"event_id": evt.ID,
}
}
if data.IsFallback {
metadata["is_fallback"] = true
}
h.mu.Unlock()
// Append this chunk to stream (frontend will accumulate by event ID)
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeAnswer,
Content: data.Content, // Just this chunk
Done: data.Done,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append answer event to stream failed", "error", err)
}
return nil
}
// handleReflection handles agent reflection events
func (h *AgentStreamHandler) handleReflection(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentReflectionData)
if !ok {
return nil
}
// Append this chunk to stream (frontend will accumulate by event ID)
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeReflection,
Content: data.Content, // Just this chunk
Done: data.Done,
Timestamp: time.Now(),
}); err != nil {
logger.GetLogger(h.ctx).Error("Append reflection event to stream failed", "error", err)
}
return nil
}
// handleError handles error events
func (h *AgentStreamHandler) handleError(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.ErrorData)
if !ok {
return nil
}
// Build error metadata
metadata := map[string]interface{}{
"stage": data.Stage,
"error": data.Error,
}
// Append error event to stream
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeError,
Content: data.Error,
Done: true,
Timestamp: time.Now(),
Data: metadata,
}); err != nil {
logger.GetLogger(h.ctx).Error("Append error event to stream failed", "error", err)
}
return nil
}
// handleSessionTitle handles session title update events
func (h *AgentStreamHandler) handleSessionTitle(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.SessionTitleData)
if !ok {
return nil
}
// Use background context for title event since it may arrive after stream completion
bgCtx := context.Background()
// Append title event to stream
if err := h.streamManager.AppendEvent(bgCtx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeSessionTitle,
Content: data.Title,
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"session_id": data.SessionID,
"title": data.Title,
},
}); err != nil {
logger.GetLogger(h.ctx).Warn("Append session title event to stream failed (stream may have ended)", "error", err)
}
return nil
}
// handleComplete handles agent complete events
func (h *AgentStreamHandler) handleComplete(ctx context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentCompleteData)
if !ok {
return nil
}
h.mu.Lock()
defer h.mu.Unlock()
// Update assistant message with final data
if data.MessageID == h.assistantMessageID {
// h.assistantMessage.Content = data.FinalAnswer
h.assistantMessage.IsCompleted = true
h.assistantMessage.AgentDurationMs = data.TotalDurationMs
// Update knowledge references if provided
if len(data.KnowledgeRefs) > 0 {
knowledgeRefs := make([]*types.SearchResult, 0, len(data.KnowledgeRefs))
for _, ref := range data.KnowledgeRefs {
if sr, ok := ref.(*types.SearchResult); ok {
knowledgeRefs = append(knowledgeRefs, sr)
}
}
h.assistantMessage.KnowledgeReferences = knowledgeRefs
}
h.assistantMessage.Content += data.FinalAnswer
// Update agent steps if provided
if data.AgentSteps != nil {
if steps, ok := data.AgentSteps.([]types.AgentStep); ok {
h.assistantMessage.AgentSteps = agenttools.SanitizeAgentStepsForStorage(steps)
}
}
// Persist the turn's aggregated LLM usage with the message so history
// reads still carry it after the live stream is gone.
if usage, ok := data.Usage.(*types.TokenUsage); ok && usage != nil {
h.assistantMessage.Usage = usage
}
// Drain skill-generated files from the sandbox into persistent
// storage. Best-effort: any failure is logged and the turn is
// persisted without artifacts. Collect is a no-op when either the
// collector wasn't wired in, no sandbox is bound, or no files were
// produced — those cases must not disturb the completion path.
if h.artifactCollector != nil {
collectCtx := context.WithoutCancel(h.ctx)
artifacts, err := h.artifactCollector.CollectWithNotify(
collectCtx,
h.sessionID,
h.assistantMessageID,
h.tenantID,
skills.ArtifactOutputDir(),
h.emitArtifactsPending,
)
if err != nil {
logger.GetLogger(h.ctx).Warnf(
"artifact collect failed session=%s message=%s: %v",
h.sessionID, h.assistantMessageID, err,
)
} else if len(artifacts) > 0 {
h.assistantMessage.Artifacts = artifacts
// The answer text names generated files the way the model saw
// them in the sandbox. Bind those names to artifact indices now
// that the index space is final, so a reloaded conversation
// renders them instead of showing a broken link.
h.assistantMessage.Content = rewriteArtifactReferences(
h.assistantMessage.Content, artifacts,
)
logger.GetLogger(h.ctx).Infof(
"artifact collect attached %d file(s) to message=%s session=%s",
len(artifacts), h.assistantMessageID, h.sessionID,
)
}
}
}
// Fallback: if no answer events were streamed but we have a final answer,
// emit it as answer events so the frontend can render it properly.
// This guards against edge cases where the LLM stops without calling final_answer.
if h.finalAnswer == "" && data.FinalAnswer != "" {
logger.GetLogger(h.ctx).Warnf(
"No answer events were streamed, emitting fallback answer (len=%d). "+
"This typically happens when: (1) model stopped naturally and content was sent as thought events, "+
"or (2) Ollama model returned tool calls non-incrementally. "+
"total_steps=%d, total_duration_ms=%d",
len(data.FinalAnswer), data.TotalSteps, data.TotalDurationMs,
)
fallbackID := fmt.Sprintf("answer-fallback-%d", time.Now().UnixMilli())
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: fallbackID,
Type: types.ResponseTypeAnswer,
Content: data.FinalAnswer,
Done: false,
Timestamp: time.Now(),
Data: map[string]interface{}{
"event_id": fallbackID,
"is_fallback": true,
},
}); err != nil {
logger.GetLogger(h.ctx).Errorf("Append fallback answer event failed: %v", err)
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: fallbackID,
Type: types.ResponseTypeAnswer,
Content: "",
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"event_id": fallbackID,
"is_fallback": true,
},
}); err != nil {
logger.GetLogger(h.ctx).Errorf("Append fallback answer done event failed: %v", err)
}
}
// Send completion event to stream manager so SSE can detect completion
completeData := map[string]interface{}{
"total_steps": data.TotalSteps,
"total_duration_ms": data.TotalDurationMs,
}
// Attach the freshly-collected artifacts so the frontend can render the
// download button without waiting for a page refresh. We strip the
// storage URL and any other server-only fields via publicArtifactViews
// — clients only ever download through /artifacts/:index which enforces
// tenant ownership.
if len(h.assistantMessage.Artifacts) > 0 {
completeData["artifacts"] = publicArtifactViews(h.assistantMessage.Artifacts)
}
// Carry the turn's aggregated LLM usage both inside data (map consumers)
// and on the typed event field, which buildStreamResponse promotes to the
// response's top-level usage.
turnUsage, _ := data.Usage.(*types.TokenUsage)
if turnUsage != nil {
completeData["usage"] = turnUsage
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: evt.ID,
Type: types.ResponseTypeComplete,
Content: "",
Done: true,
Timestamp: time.Now(),
Data: completeData,
Usage: turnUsage,
}); err != nil {
logger.GetLogger(h.ctx).Errorf("Append complete event to stream failed: %v", err)
}
return nil
}
// emitArtifactsPending tells the live UI that sandbox files exist and are
// being uploaded. It must not take h.mu — Collect calls it while
// handleComplete already holds the lock.
func (h *AgentStreamHandler) emitArtifactsPending(count int) {
if h == nil || h.streamManager == nil || count <= 0 {
return
}
if err := h.streamManager.AppendEvent(h.ctx, h.sessionID, h.assistantMessageID, interfaces.StreamEvent{
ID: fmt.Sprintf("artifacts-pending-%d", time.Now().UnixMilli()),
Type: types.ResponseTypeArtifactsPending,
Timestamp: time.Now(),
Data: map[string]interface{}{
"count": count,
},
}); err != nil {
logger.GetLogger(h.ctx).Warnf(
"append artifacts_pending failed session=%s message=%s: %v",
h.sessionID, h.assistantMessageID, err,
)
}
}
// publicArtifactViews returns a redacted view of the artifact list suitable
// for direct serialization onto the SSE stream. The physical storage path is
// stripped; the resource handle is kept because it is what the answer body
// references, and the frontend needs it to tie an inline reference to the file
// it names. Bytes are fetched through /artifacts/:index/download.
func publicArtifactViews(list types.MessageArtifacts) []map[string]interface{} {
out := make([]map[string]interface{}, 0, len(list))
for i, a := range list {
out = append(out, map[string]interface{}{
"index": i,
"handle": artifactHandle(a),
"file_name": a.FileName,
"file_type": a.FileType,
"file_size": a.FileSize,
"source_path": a.SourcePath,
"mod_time": a.ModTime,
"created_at": a.CreatedAt,
})
}
return out
}
@@ -0,0 +1,317 @@
package session
import (
stderrors "errors"
"io"
"mime"
"net/http"
"path/filepath"
"strconv"
"strings"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// paramSessionID resolves the session-id URL parameter regardless of which
// wildcard name the current route uses. GET-tree routes bind :id (to align
// with /sessions/:id), while POST-tree routes typically bind :session_id.
// Handlers call this helper instead of hard-coding one name so the same
// function serves both trees.
func paramSessionID(c *gin.Context) string {
if v := c.Param("session_id"); v != "" {
return v
}
return c.Param("id")
}
// ListSessionArtifacts godoc
// @Summary 列出会话生成的产物文件
// @Description 返回本会话中所有 assistant 消息产生的技能产物元数据(不含 URL)
// @Tags 会话
// @Produce json
// @Param session_id path string true "会话ID"
// @Success 200 {object} map[string]interface{}
// @Failure 404 {object} errors.AppError
// @Security Bearer
// @Router /sessions/{session_id}/artifacts [get]
//
// The endpoint powers the drawer that lists every file generated in the
// session; it does NOT return the storage URL (only names/sizes/mtimes), so
// clients cannot reach around the download endpoint by reading a
// provider:// path from the API response.
func (h *Handler) ListSessionArtifacts(c *gin.Context) {
ctx := c.Request.Context()
sessionID := secutils.SanitizeForLog(paramSessionID(c))
if sessionID == "" {
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Ownership + tenant check: GetSession enforces both. Returning 404 for
// unknown / non-owned sessions matches the rest of the session routes.
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
artifacts, err := h.messageService.GetSessionArtifacts(ctx, sessionID)
if err != nil {
logger.Errorf(ctx, "list session artifacts failed: session=%s err=%v", sessionID, err)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
items := make([]artifactListItem, 0, len(artifacts))
for i, a := range artifacts {
items = append(items, artifactListItem{
Index: i,
Handle: artifactHandle(a),
FileName: a.FileName,
FileType: a.FileType,
FileSize: a.FileSize,
SourcePath: a.SourcePath,
ModTime: a.ModTime,
CreatedAt: a.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": items,
})
}
// ListMessageArtifacts returns just the artifacts attached to a single
// assistant message. Used by the "download files from this reply" button on
// each bot message.
//
// Same-tenant/same-owner check flows through h.sessionService.GetSession
// exactly like ListSessionArtifacts.
//
// @Router /sessions/{session_id}/messages/{message_id}/artifacts [get]
func (h *Handler) ListMessageArtifacts(c *gin.Context) {
ctx := c.Request.Context()
sessionID := secutils.SanitizeForLog(paramSessionID(c))
messageID := secutils.SanitizeForLog(c.Param("message_id"))
if sessionID == "" || messageID == "" {
c.Error(errors.NewBadRequestError("session_id and message_id are required"))
return
}
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
msg, err := h.messageService.GetMessage(ctx, sessionID, messageID)
if err != nil || msg == nil {
c.Error(errors.NewNotFoundError("message not found"))
return
}
items := make([]artifactListItem, 0, len(msg.Artifacts))
for i, a := range msg.Artifacts {
items = append(items, artifactListItem{
Index: i,
Handle: artifactHandle(a),
FileName: a.FileName,
FileType: a.FileType,
FileSize: a.FileSize,
SourcePath: a.SourcePath,
ModTime: a.ModTime,
CreatedAt: a.CreatedAt,
})
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": items,
})
}
// DownloadMessageArtifact streams a single skill-generated file to the
// client. Clients reference the artifact by its position (:index) in the
// assistant message's Artifacts array; the storage URL never leaves the
// server so callers cannot pivot to arbitrary blobs.
//
// @Router /sessions/{session_id}/messages/{message_id}/artifacts/{index}/download [get]
func (h *Handler) DownloadMessageArtifact(c *gin.Context) {
ctx := c.Request.Context()
sessionID := secutils.SanitizeForLog(paramSessionID(c))
messageID := secutils.SanitizeForLog(c.Param("message_id"))
indexParam := c.Param("index")
if sessionID == "" || messageID == "" || indexParam == "" {
c.Error(errors.NewBadRequestError("session_id, message_id and index are required"))
return
}
index, err := strconv.Atoi(indexParam)
if err != nil || index < 0 {
c.Error(errors.NewBadRequestError("invalid artifact index"))
return
}
// Ownership check: GetSession returns ErrSessionNotFound when the
// session doesn't belong to the calling tenant/user, so a 404 covers
// both "not found" and "forbidden" without leaking existence.
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
c.Error(errors.NewNotFoundError(err.Error()))
return
}
c.Error(errors.NewInternalServerError(err.Error()))
return
}
msg, err := h.messageService.GetMessage(ctx, sessionID, messageID)
if err != nil || msg == nil {
c.Error(errors.NewNotFoundError("message not found"))
return
}
if index >= len(msg.Artifacts) {
c.Error(errors.NewNotFoundError("artifact index out of range"))
return
}
artifact := msg.Artifacts[index]
if artifact.URL == "" {
c.Error(errors.NewNotFoundError("artifact storage path missing"))
return
}
if h.fileService == nil {
c.Error(errors.NewInternalServerError("file service unavailable"))
return
}
reader, err := h.fileService.GetFile(ctx, artifact.URL)
if err != nil {
logger.Warnf(ctx, "artifact download read failed: session=%s message=%s idx=%d err=%v",
sessionID, messageID, index, err)
c.Error(errors.NewNotFoundError("artifact blob missing"))
return
}
defer reader.Close()
// Force download semantics — artifacts are never rendered inline, matching
// the /files endpoint's active-content protection.
c.Header("Content-Type", mimeTypeFor(artifact.FileName))
c.Header("X-Content-Type-Options", "nosniff")
c.Header("Content-Disposition", buildAttachmentHeader(artifact.FileName))
if artifact.FileSize > 0 {
c.Header("Content-Length", strconv.FormatInt(artifact.FileSize, 10))
}
c.Status(http.StatusOK)
if _, err := io.Copy(c.Writer, reader); err != nil {
logger.Warnf(ctx, "artifact download stream failed: session=%s message=%s idx=%d err=%v",
sessionID, messageID, index, err)
}
}
// artifactListItem is the JSON shape returned by ListSessionArtifacts /
// ListMessageArtifacts. It carries the resource handle but never the storage
// path: the handle is the artifact's public identity — it is what the answer
// body references and what an authorizing proxy resolves — while the physical
// bucket/key stays server side.
type artifactListItem struct {
Index int `json:"index"`
// Handle is the artifact's `resource://<handle>` reference, matching the
// destinations in the message body. Empty when the deployment runs without
// a resource catalog, in which case the body references files by name.
Handle string `json:"handle,omitempty"`
FileName string `json:"file_name"`
FileType string `json:"file_type"`
FileSize int64 `json:"file_size"`
SourcePath string `json:"source_path"`
// time-typed fields serialise as RFC3339 strings — same convention as
// the rest of the messages API.
ModTime any `json:"mod_time"`
CreatedAt any `json:"created_at"`
}
// mimeTypeFor picks a Content-Type by extension and falls back to
// application/octet-stream so unknown types force a download prompt rather
// than being sniffed. Kept private to this file — the /files route has a
// stricter version with an SVG-neutralising branch; we don't need that here
// because Content-Disposition already blocks inline rendering.
func mimeTypeFor(name string) string {
ext := strings.ToLower(filepath.Ext(name))
if ext == "" {
return "application/octet-stream"
}
if ct := mime.TypeByExtension(ext); ct != "" {
return ct
}
return "application/octet-stream"
}
// buildAttachmentHeader returns a Content-Disposition value that preserves
// non-ASCII filenames (RFC 5987) while providing a safe fallback for
// ASCII-only clients.
func buildAttachmentHeader(name string) string {
// Strip control characters + quotes; keep the human-readable name.
ascii := strings.Map(func(r rune) rune {
if r < 0x20 || r == 0x7f {
return -1
}
if r == '"' || r == '\\' {
return '_'
}
if r > 0x7e {
return -1
}
return r
}, name)
if ascii == "" {
ascii = "download"
}
encoded := (&urlPathEscaper{}).escape(name)
return "attachment; filename=\"" + ascii + "\"; filename*=UTF-8''" + encoded
}
// urlPathEscaper is a minimal RFC 3986 percent-encoder for the subset of
// bytes allowed in a filename*= value. We inline it to avoid importing
// net/url just for a two-line call, and because url.PathEscape encodes
// spaces as "+" (form-encoding) which HTTP clients then decode as literal
// "+" characters in the filename.
type urlPathEscaper struct{}
// escape percent-encodes every byte outside the "attr-char" grammar of RFC 5987.
// See https://datatracker.ietf.org/doc/html/rfc5987#section-3.2.1
func (urlPathEscaper) escape(s string) string {
var b strings.Builder
b.Grow(len(s))
const hex = "0123456789ABCDEF"
for i := 0; i < len(s); i++ {
c := s[i]
switch {
case (c >= 'A' && c <= 'Z'), (c >= 'a' && c <= 'z'), (c >= '0' && c <= '9'):
b.WriteByte(c)
case c == '-' || c == '.' || c == '_' || c == '~':
b.WriteByte(c)
default:
b.WriteByte('%')
b.WriteByte(hex[c>>4])
b.WriteByte(hex[c&0x0f])
}
}
return b.String()
}
// artifactHandle returns the artifact's `resource://<handle>` reference, or ""
// when the deployment stores artifacts without a resource catalog.
func artifactHandle(artifact types.MessageArtifact) string {
if handle, ok := types.ParseResourcePath(artifact.URL); ok {
return types.BuildResourcePath(handle)
}
return ""
}
@@ -0,0 +1,271 @@
package session
import (
"context"
stderrors "errors"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// -----------------------------------------------------------------------------
// Test doubles
//
// The download handler only touches three collaborators: SessionService (for
// ownership check), MessageService (for the message + artifact list), and
// FileService (for the blob stream). We embed the full interfaces so the
// zero-value struct compiles, then override just the methods each test
// exercises. A stray call to an un-stubbed method deliberately nil-panics
// so the failure surfaces immediately.
// -----------------------------------------------------------------------------
type stubSessionServiceForArtifacts struct {
interfaces.SessionService
getSession func(ctx context.Context, id string) (*types.Session, error)
}
func (s *stubSessionServiceForArtifacts) GetSession(ctx context.Context, id string) (*types.Session, error) {
return s.getSession(ctx, id)
}
type stubMessageServiceForArtifacts struct {
interfaces.MessageService
getMessage func(ctx context.Context, sessionID, id string) (*types.Message, error)
getSessionArtifact func(ctx context.Context, sessionID string) (types.MessageArtifacts, error)
}
func (s *stubMessageServiceForArtifacts) GetMessage(ctx context.Context, sessionID, id string) (*types.Message, error) {
return s.getMessage(ctx, sessionID, id)
}
func (s *stubMessageServiceForArtifacts) GetSessionArtifacts(ctx context.Context, sessionID string) (types.MessageArtifacts, error) {
if s.getSessionArtifact == nil {
return types.MessageArtifacts{}, nil
}
return s.getSessionArtifact(ctx, sessionID)
}
// fakeArtifactFileService serves canned bytes for a single URL.
type fakeArtifactFileService struct {
interfaces.FileService
url string
data []byte
}
func (f *fakeArtifactFileService) GetFile(_ context.Context, url string) (io.ReadCloser, error) {
if url != f.url {
return nil, stderrors.New("not found")
}
return io.NopCloser(strings.NewReader(string(f.data))), nil
}
func (f *fakeArtifactFileService) SaveFile(_ context.Context, _ *multipart.FileHeader, _ uint64, _ string) (string, error) {
return "", nil
}
func (f *fakeArtifactFileService) SaveBytes(_ context.Context, _ []byte, _ uint64, _ string, _ bool) (string, error) {
return "", nil
}
func (f *fakeArtifactFileService) GetFileURL(_ context.Context, _ string) (string, error) {
return "", nil
}
func (f *fakeArtifactFileService) DeleteFile(_ context.Context, _ string) error {
return nil
}
// -----------------------------------------------------------------------------
// Router builder
// -----------------------------------------------------------------------------
func newArtifactTestRouter(h *Handler) *gin.Engine {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
// Match the production route's wildcard names (see router.go — GET tree
// binds :id to align with /sessions/:id) so paramSessionID resolves via
// the same code path exercised in prod.
r.GET("/sessions/:id/artifacts", h.ListSessionArtifacts)
r.GET("/sessions/:id/messages/:message_id/artifacts", h.ListMessageArtifacts)
r.GET("/sessions/:id/messages/:message_id/artifacts/:index/download", h.DownloadMessageArtifact)
return r
}
// -----------------------------------------------------------------------------
// Tests
// -----------------------------------------------------------------------------
func TestDownloadMessageArtifact_HappyPath(t *testing.T) {
sessionID := "sess-1"
messageID := "msg-1"
url := "fake://tenant-42/report.pptx"
body := []byte("PPTX-BYTES")
h := &Handler{
sessionService: &stubSessionServiceForArtifacts{
getSession: func(_ context.Context, id string) (*types.Session, error) {
if id != sessionID {
return nil, apperrors.ErrSessionNotFound
}
return &types.Session{ID: id, TenantID: 42}, nil
},
},
messageService: &stubMessageServiceForArtifacts{
getMessage: func(_ context.Context, sid, mid string) (*types.Message, error) {
if sid != sessionID || mid != messageID {
return nil, nil
}
return &types.Message{
ID: mid, SessionID: sid,
Artifacts: types.MessageArtifacts{{
URL: url,
FileName: "报告.pptx", // non-ASCII to exercise RFC 5987 encoding
FileType: ".pptx",
FileSize: int64(len(body)),
}},
}, nil
},
},
fileService: &fakeArtifactFileService{url: url, data: body},
}
router := newArtifactTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/sessions/sess-1/messages/msg-1/artifacts/0/download", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
if got := w.Body.String(); got != string(body) {
t.Fatalf("body = %q, want %q", got, string(body))
}
cd := w.Header().Get("Content-Disposition")
if !strings.HasPrefix(cd, "attachment;") {
t.Fatalf("Content-Disposition = %q, want attachment prefix", cd)
}
if !strings.Contains(cd, "filename*=UTF-8''") {
t.Fatalf("Content-Disposition = %q, want RFC 5987 filename*", cd)
}
if got := w.Header().Get("X-Content-Type-Options"); got != "nosniff" {
t.Fatalf("X-Content-Type-Options = %q, want nosniff", got)
}
}
func TestDownloadMessageArtifact_SessionNotOwnedReturns404(t *testing.T) {
h := &Handler{
sessionService: &stubSessionServiceForArtifacts{
getSession: func(_ context.Context, _ string) (*types.Session, error) {
return nil, apperrors.ErrSessionNotFound
},
},
}
router := newArtifactTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/sessions/sess-x/messages/msg-x/artifacts/0/download", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404", w.Code)
}
}
func TestDownloadMessageArtifact_IndexOutOfRange(t *testing.T) {
sessionID := "sess-1"
messageID := "msg-1"
h := &Handler{
sessionService: &stubSessionServiceForArtifacts{
getSession: func(_ context.Context, _ string) (*types.Session, error) {
return &types.Session{ID: sessionID, TenantID: 42}, nil
},
},
messageService: &stubMessageServiceForArtifacts{
getMessage: func(_ context.Context, _, _ string) (*types.Message, error) {
return &types.Message{ID: messageID, SessionID: sessionID, Artifacts: types.MessageArtifacts{}}, nil
},
},
}
router := newArtifactTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/sessions/sess-1/messages/msg-1/artifacts/7/download", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusNotFound {
t.Fatalf("status = %d, want 404 for out-of-range index", w.Code)
}
}
func TestDownloadMessageArtifact_InvalidIndex(t *testing.T) {
h := &Handler{
sessionService: &stubSessionServiceForArtifacts{
getSession: func(_ context.Context, _ string) (*types.Session, error) {
return &types.Session{ID: "sess-1", TenantID: 42}, nil
},
},
}
router := newArtifactTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/sessions/sess-1/messages/msg-1/artifacts/not-a-number/download", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusBadRequest {
t.Fatalf("status = %d, want 400 for non-integer index", w.Code)
}
}
func TestListSessionArtifacts_StripsURL(t *testing.T) {
h := &Handler{
sessionService: &stubSessionServiceForArtifacts{
getSession: func(_ context.Context, _ string) (*types.Session, error) {
return &types.Session{ID: "sess-1", TenantID: 42}, nil
},
},
messageService: &stubMessageServiceForArtifacts{
getSessionArtifact: func(_ context.Context, _ string) (types.MessageArtifacts, error) {
return types.MessageArtifacts{
{URL: "fake://internal/1", FileName: "a.txt", FileSize: 1, CreatedAt: time.Now()},
}, nil
},
},
}
router := newArtifactTestRouter(h)
req := httptest.NewRequest(http.MethodGet, "/sessions/sess-1/artifacts", nil)
w := httptest.NewRecorder()
router.ServeHTTP(w, req)
if w.Code != http.StatusOK {
t.Fatalf("status = %d, want 200 (body=%s)", w.Code, w.Body.String())
}
// The URL must not appear in the response body: it's an internal
// storage path and leaking it defeats the download endpoint's
// ownership check.
if strings.Contains(w.Body.String(), "fake://internal/1") {
t.Fatalf("response body leaked storage URL: %s", w.Body.String())
}
if !strings.Contains(w.Body.String(), "a.txt") {
t.Fatalf("response body missing file name: %s", w.Body.String())
}
}
func TestBuildAttachmentHeader_CJK(t *testing.T) {
// The RFC 5987 filename* segment must percent-encode every non-attr-char
// byte so downstream browsers preserve "报告.pptx" instead of mojibake.
got := buildAttachmentHeader("报告.pptx")
if !strings.Contains(got, "filename=") {
t.Fatalf("missing ASCII filename fallback: %q", got)
}
if !strings.Contains(got, "filename*=UTF-8''") {
t.Fatalf("missing RFC 5987 filename*: %q", got)
}
// Non-ASCII bytes must be percent-encoded (never appear literally).
if strings.ContainsRune(got, '报') {
t.Fatalf("filename* contains raw CJK: %q", got)
}
}
@@ -0,0 +1,270 @@
package session
import (
"net/url"
"path"
"regexp"
"strings"
"github.com/Tencent/WeKnora/internal/types"
)
// Models routinely reference the files they generated in the sandbox from
// their final answer, most often as a Markdown image (`![评分](市场画像评分.html)`).
// A bare file name resolves to nothing once the sandbox is gone, so this is the
// boundary where an answer's transient, sandbox-local references are normalized
// into the one reference form that survives: `resource://<handle>`.
//
// The handle is the same identity every other stored file uses (knowledge-base
// images, chat attachments), which is what makes a saved answer keep working:
// re-binding the handle to a knowledge entry is enough, with no copy and no
// second rewrite. It is also opaque — the physical storage path stays server
// side, and every read still goes through an authorizing proxy.
//
// A deployment without the resource catalog has no handle to point at, so those
// references are normalized to the canonical `sandbox:<name>` spelling instead
// and resolved by name against the message's own artifact list. That keeps the
// answer readable in the chat, and only there.
//
// The rewrite runs once per turn, after ArtifactCollector has drained
// /workspace/output, so every artifact already has its handle.
// fencedOrInlineCodeRE splits content so destinations inside code samples are
// never rewritten — a documentation snippet showing the syntax must survive.
var fencedOrInlineCodeRE = regexp.MustCompile("(?s)(```.*?```|~~~.*?~~~|`[^`\n]*`)")
// schemeRE matches an already-qualified URL (http://, resource://, data:, …).
var schemeRE = regexp.MustCompile(`^[A-Za-z][A-Za-z0-9+.\-]*:`)
// titleSuffixRE splits the optional title off a link destination
// (`file.png "caption"`).
var titleSuffixRE = regexp.MustCompile(`(?s)^(.*?)(\s+(?:"[^"]*"|'[^']*'))$`)
// rewriteArtifactReferences replaces every Markdown link/image destination that
// names one of the turn's artifacts with that artifact's stable reference.
//
// Three destination spellings are accepted, because models are inconsistent
// about the prefix even when the prompt asks for one:
//
// ![评分](sandbox:市场画像评分.html)
// ![评分](市场画像评分.html)
// ![评分](./output/市场画像评分.html)
//
// Anything else — an http URL, a resource:// handle the model copied from
// context, an unknown file name — is returned unchanged so existing behaviour
// (knowledge-base images, web links) is untouched.
func rewriteArtifactReferences(content string, artifacts types.MessageArtifacts) string {
if content == "" || len(artifacts) == 0 {
return content
}
byName := artifactRefByName(artifacts)
if len(byName) == 0 {
return content
}
parts := fencedOrInlineCodeRE.Split(content, -1)
if len(parts) == 1 {
return rewriteArtifactReferencesInSegment(content, byName)
}
code := fencedOrInlineCodeRE.FindAllString(content, -1)
var out strings.Builder
out.Grow(len(content))
for i, part := range parts {
out.WriteString(rewriteArtifactReferencesInSegment(part, byName))
if i < len(code) {
out.WriteString(code[i])
}
}
return out.String()
}
// rewriteArtifactReferencesInSegment walks every inline Markdown link/image in
// one non-code segment and rebinds the ones that name an artifact.
//
// Destinations are located by matching parentheses rather than by regex,
// because skill-generated file names routinely contain both spaces and
// parentheses (`腾讯控股(00700) 成交量_838ccc.html`). A regex that stops at the
// first space or paren would capture half the name and never match.
func rewriteArtifactReferencesInSegment(segment string, byName map[string]string) string {
if segment == "" || !strings.Contains(segment, "](") {
return segment
}
var out strings.Builder
out.Grow(len(segment))
cursor := 0
for cursor < len(segment) {
relative := strings.Index(segment[cursor:], "](")
if relative < 0 {
break
}
closeBracket := cursor + relative
open := closeBracket + 1
inner, end, ok := scanLinkDestination(segment, open)
if !ok || !hasLinkLabelBefore(segment, closeBracket) {
out.WriteString(segment[cursor : open+1])
cursor = open + 1
continue
}
destination, title := splitDestinationTitle(inner)
ref, matched := lookupArtifactRef(destination, byName, markdownImageBefore(segment, closeBracket))
if !matched {
out.WriteString(segment[cursor : end+1])
cursor = end + 1
continue
}
out.WriteString(segment[cursor : open+1])
out.WriteString(ref)
out.WriteString(title)
out.WriteString(")")
cursor = end + 1
}
out.WriteString(segment[cursor:])
return out.String()
}
// scanLinkDestination returns the text between the `(` at openIndex and its
// matching `)`, plus the index of that closing paren. A destination never spans
// a line break, so a newline ends the scan unsuccessfully.
func scanLinkDestination(text string, openIndex int) (string, int, bool) {
depth := 1
for i := openIndex + 1; i < len(text); i++ {
switch text[i] {
case '\n':
return "", 0, false
case '(':
depth++
case ')':
depth--
if depth == 0 {
return text[openIndex+1 : i], i, true
}
}
}
return "", 0, false
}
// hasLinkLabelBefore reports whether `](` is preceded by a `[` on the same
// line, i.e. whether this really is a link rather than incidental punctuation.
func hasLinkLabelBefore(text string, closeBracketIndex int) bool {
for i := closeBracketIndex - 1; i >= 0; i-- {
switch text[i] {
case '\n':
return false
case '[':
return true
}
}
return false
}
// splitDestinationTitle separates `dest "title"` into its two parts, returning
// the title with its leading whitespace so it can be re-emitted verbatim.
func splitDestinationTitle(inner string) (string, string) {
if groups := titleSuffixRE.FindStringSubmatch(inner); groups != nil {
return strings.TrimSpace(groups[1]), groups[2]
}
return strings.TrimSpace(inner), ""
}
func markdownImageBefore(text string, closeBracketIndex int) bool {
for i := closeBracketIndex - 1; i >= 0; i-- {
switch text[i] {
case '\n':
return false
case '[':
return i > 0 && text[i-1] == '!'
}
}
return false
}
func looksLikeSandboxOutputPath(candidate string) bool {
c := strings.TrimPrefix(strings.TrimSpace(candidate), "./")
c = strings.TrimPrefix(c, "/")
return strings.HasPrefix(c, "workspace/output/") || strings.HasPrefix(c, "output/")
}
// lookupArtifactRef resolves one Markdown destination to an artifact reference.
func lookupArtifactRef(destination string, byName map[string]string, image bool) (string, bool) {
candidate := strings.TrimSpace(destination)
if candidate == "" {
return "", false
}
// Trim the optional angle-bracket form Markdown allows around a
// destination: `[x](<name with space>)` — kept for completeness even
// though the outer regex rejects inner whitespace.
candidate = strings.TrimPrefix(strings.TrimSuffix(candidate, ">"), "<")
hadSandboxPrefix := false
for _, prefix := range []string{"sandbox://", "sandbox:"} {
if len(candidate) >= len(prefix) && strings.EqualFold(candidate[:len(prefix)], prefix) {
candidate = candidate[len(prefix):]
hadSandboxPrefix = true
break
}
}
// A destination that already carries a scheme is a real URL, not a file
// name. `sandbox:` is the one exception and was stripped above.
if !hadSandboxPrefix && schemeRE.MatchString(candidate) {
return "", false
}
// Models percent-encode non-ASCII names about half the time.
if decoded, err := url.PathUnescape(candidate); err == nil {
candidate = decoded
}
candidate = strings.TrimSpace(candidate)
// Bare names are rewritten for images (`![chart](a.html)`) because that is
// how models actually cite generated files. Ordinary links (`[docs](README.md)`)
// are left alone unless they already carry a sandbox prefix or path —
// otherwise a colliding artifact name would hijack a real hyperlink.
if !hadSandboxPrefix && !image && !looksLikeSandboxOutputPath(candidate) {
return "", false
}
// Directory prefixes (`./`, `output/`, `/workspace/output/`) carry no
// information the artifact list does not already have.
candidate = path.Base(candidate)
if candidate == "" || candidate == "." || candidate == "/" {
return "", false
}
ref, ok := byName[candidate]
return ref, ok
}
// artifactRefByName maps each artifact's file name to the reference that
// replaces it in the answer. A duplicate name keeps the first occurrence:
// silently preferring the later file would make the reference point at
// something the model did not describe.
func artifactRefByName(artifacts types.MessageArtifacts) map[string]string {
byName := make(map[string]string, len(artifacts))
for _, artifact := range artifacts {
name := strings.TrimSpace(artifact.FileName)
if name == "" {
continue
}
if _, exists := byName[name]; exists {
continue
}
byName[name] = artifactReference(artifact)
}
return byName
}
// artifactReference returns the destination an answer should carry for one
// artifact: its catalog handle when the deployment has a resource registry,
// otherwise the canonical chat-only `sandbox:<name>` form.
//
// The raw storage path is never a candidate. It names a bucket and key, which
// is both an information leak and useless to a client that has no credentials
// for the object store.
func artifactReference(artifact types.MessageArtifact) string {
if handle, ok := types.ParseResourcePath(artifact.URL); ok {
return types.BuildResourcePath(handle)
}
return "sandbox:" + strings.TrimSpace(artifact.FileName)
}
@@ -0,0 +1,208 @@
package session
import (
"fmt"
"strings"
"testing"
"github.com/Tencent/WeKnora/internal/types"
)
// handleFor builds a syntactically valid 22-character resource handle for
// artifact i, so tests exercise the same ParseResourcePath path as production.
func handleFor(i int) string {
base := fmt.Sprintf("art%d", i)
return base + strings.Repeat("x", types.ResourceHandleLength-len(base))
}
func refFor(i int) string {
return types.BuildResourcePath(handleFor(i))
}
// artifactsFixture builds artifacts whose storage URL is a catalog handle —
// the normal deployment. Use artifactsWithoutCatalog for the degraded case.
func artifactsFixture(names ...string) types.MessageArtifacts {
list := make(types.MessageArtifacts, 0, len(names))
for i, name := range names {
list = append(list, types.MessageArtifact{FileName: name, URL: refFor(i)})
}
return list
}
func artifactsWithoutCatalog(names ...string) types.MessageArtifacts {
list := make(types.MessageArtifacts, 0, len(names))
for _, name := range names {
list = append(list, types.MessageArtifact{FileName: name, URL: "local://7/exports/" + name})
}
return list
}
func TestRewriteArtifactReferences(t *testing.T) {
artifacts := artifactsFixture(
"市场画像评分_e7edba.html",
"concept_ranking.csv",
"trend.png",
"腾讯控股(00700) 成交量_838ccc.html",
)
cases := []struct {
name string
content string
want string
}{
{
name: "file name with spaces and parentheses",
content: "![成交量](sandbox:腾讯控股(00700) 成交量_838ccc.html)",
want: "![成交量](" + refFor(3) + ")",
},
{
name: "file name with spaces and parentheses, no prefix",
content: "![成交量](腾讯控股(00700) 成交量_838ccc.html)",
want: "![成交量](" + refFor(3) + ")",
},
{
name: "bare file name in image",
content: "![市场画像评分](市场画像评分_e7edba.html)",
want: "![市场画像评分](" + refFor(0) + ")",
},
{
name: "sandbox prefix",
content: "![评分](sandbox:市场画像评分_e7edba.html)",
want: "![评分](" + refFor(0) + ")",
},
{
name: "sandbox scheme with slashes",
content: "![评分](sandbox://trend.png)",
want: "![评分](" + refFor(2) + ")",
},
{
name: "directory prefix is dropped",
content: "[榜单](/workspace/output/concept_ranking.csv)",
want: "[榜单](" + refFor(1) + ")",
},
{
name: "percent-encoded name",
content: "![评分](%E5%B8%82%E5%9C%BA%E7%94%BB%E5%83%8F%E8%AF%84%E5%88%86_e7edba.html)",
want: "![评分](" + refFor(0) + ")",
},
{
name: "title is preserved",
content: `![评分](trend.png "走势")`,
want: `![评分](` + refFor(2) + ` "走势")`,
},
{
name: "ordinary link with a colliding name is not rewritten",
content: "见 [说明](trend.png)",
want: "见 [说明](trend.png)",
},
{
name: "sandbox-prefixed link is rewritten even when not an image",
content: "数据见 [表格](sandbox:concept_ranking.csv)",
want: "数据见 [表格](" + refFor(1) + ")",
},
{
name: "already-rewritten reference is left alone",
content: "![评分](" + refFor(0) + ")",
want: "![评分](" + refFor(0) + ")",
},
{
name: "prose parentheses are not link destinations",
content: "腾讯控股(00700) 的成交量见下图。",
want: "腾讯控股(00700) 的成交量见下图。",
},
{
name: "unknown file name untouched",
content: "![别的](missing.html)",
want: "![别的](missing.html)",
},
{
name: "http url untouched",
content: "![远程](https://example.com/trend.png)",
want: "![远程](https://example.com/trend.png)",
},
{
name: "knowledge base image untouched",
content: "![资源](resource://abcdefghijklmnopqrstuv)",
want: "![资源](resource://abcdefghijklmnopqrstuv)",
},
{
name: "fenced code untouched",
content: "```\n![评分](trend.png)\n```",
want: "```\n![评分](trend.png)\n```",
},
{
name: "inline code untouched",
content: "写成 `![评分](trend.png)` 即可",
want: "写成 `![评分](trend.png)` 即可",
},
{
name: "plain prose untouched",
content: "生成了 trend.png 和 concept_ranking.csv 两个文件。",
want: "生成了 trend.png 和 concept_ranking.csv 两个文件。",
},
}
for _, tc := range cases {
t.Run(tc.name, func(t *testing.T) {
if got := rewriteArtifactReferences(tc.content, artifacts); got != tc.want {
t.Fatalf("rewriteArtifactReferences() = %q, want %q", got, tc.want)
}
})
}
}
func TestRewriteArtifactReferencesMixedContent(t *testing.T) {
artifacts := artifactsFixture("chart.html", "data.csv")
content := "## 图表\n\n![图表](chart.html)\n\n数据见 [表格](sandbox:data.csv)," +
"外链 [文档](https://example.com/chart.html) 不受影响。"
want := "## 图表\n\n![图表](" + refFor(0) + ")\n\n数据见 [表格](" + refFor(1) + ")," +
"外链 [文档](https://example.com/chart.html) 不受影响。"
if got := rewriteArtifactReferences(content, artifacts); got != want {
t.Fatalf("rewriteArtifactReferences() = %q, want %q", got, want)
}
}
// A knowledge-base image and a skill artifact routinely appear in the same
// answer. Only the artifact is rebound; the existing reference must survive
// byte for byte, since it already is the canonical form.
func TestRewriteArtifactReferencesKeepsExistingResourceImages(t *testing.T) {
artifacts := artifactsFixture("chart.html")
kbImage := types.BuildResourcePath(strings.Repeat("Z", types.ResourceHandleLength))
content := "![检索图](" + kbImage + ")\n\n![图表](chart.html)"
want := "![检索图](" + kbImage + ")\n\n![图表](" + refFor(0) + ")"
if got := rewriteArtifactReferences(content, artifacts); got != want {
t.Fatalf("rewriteArtifactReferences() = %q, want %q", got, want)
}
}
// Without a resource catalog there is no durable handle, so references are
// normalized to the chat-only sandbox form rather than leaking a storage path.
func TestRewriteArtifactReferencesWithoutCatalog(t *testing.T) {
artifacts := artifactsWithoutCatalog("chart.html")
got := rewriteArtifactReferences("![图表](chart.html)", artifacts)
if want := "![图表](sandbox:chart.html)"; got != want {
t.Fatalf("rewriteArtifactReferences() = %q, want %q", got, want)
}
if strings.Contains(got, "local://") {
t.Fatalf("storage path leaked into content: %q", got)
}
}
func TestRewriteArtifactReferencesNoArtifacts(t *testing.T) {
content := "![图表](chart.html)"
if got := rewriteArtifactReferences(content, nil); got != content {
t.Fatalf("rewriteArtifactReferences() = %q, want unchanged", got)
}
}
func TestArtifactRefByNameKeepsFirstDuplicate(t *testing.T) {
byName := artifactRefByName(artifactsFixture("a.html", "a.html", "b.html"))
if byName["a.html"] != refFor(0) {
t.Fatalf("duplicate name resolved to %q, want %q", byName["a.html"], refFor(0))
}
if byName["b.html"] != refFor(2) {
t.Fatalf("b.html resolved to %q, want %q", byName["b.html"], refFor(2))
}
}
@@ -0,0 +1,364 @@
package session
import (
"bufio"
"bytes"
"context"
"encoding/base64"
"fmt"
"path/filepath"
"strings"
"github.com/Tencent/WeKnora/internal/common"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/google/uuid"
)
const (
// maxTextFileLines is the line limit for inline text content; excess lines are truncated.
maxTextFileLines = 500
// textFileExtensions lists plain-text extensions handled by the line-based reader.
textFileExtensions = ".txt,.md,.markdown,.json,.xml,.yaml,.yml,.csv,.log"
)
// AttachmentProcessor saves uploaded file attachments and extracts their text content
// for injection into the LLM prompt.
type AttachmentProcessor struct {
fileService interfaces.FileService
documentReader interfaces.DocumentReader
imageResolver *docparser.ImageResolver
modelService interfaces.ModelService // used to obtain the ASR model
}
// NewAttachmentProcessor creates an AttachmentProcessor with the given dependencies.
func NewAttachmentProcessor(
fileService interfaces.FileService,
documentReader interfaces.DocumentReader,
imageResolver *docparser.ImageResolver,
modelService interfaces.ModelService,
) *AttachmentProcessor {
return &AttachmentProcessor{
fileService: fileService,
documentReader: documentReader,
imageResolver: imageResolver,
modelService: modelService,
}
}
// ProcessAttachment validates, saves, and extracts content from a single uploaded file.
// Content extraction is attempted for all supported types; errors are non-fatal (logged as warnings).
func (p *AttachmentProcessor) ProcessAttachment(
ctx context.Context,
data []byte,
fileName string,
fileSize int64,
tenantID uint64,
asrModelID string, // optional; enables audio transcription when set
) (*types.MessageAttachment, error) {
logger.Infof(ctx, "processing attachment: fileName=%s, fileSize=%d", secutils.SanitizeForLog(fileName), fileSize)
// Validate filename (injection / path-traversal checks)
safeFileName, isValid := secutils.ValidateInput(fileName)
if !isValid {
return nil, fmt.Errorf("invalid characters in file name")
}
baseName, err := secutils.SafeFileName(safeFileName)
if err != nil {
return nil, fmt.Errorf("unsafe file name: %w", err)
}
ext := strings.ToLower(filepath.Ext(baseName))
if ext == "" {
ext = ".txt"
}
if !isValidFileType(baseName) {
return nil, fmt.Errorf("unsupported file type: %s", ext)
}
uniqueFileName := fmt.Sprintf("attachment_%s%s", uuid.New().String()[:12], ext)
storageURL, err := p.fileService.SaveBytes(ctx, data, tenantID, uniqueFileName, false)
if err != nil {
return nil, fmt.Errorf("failed to save attachment: %w", err)
}
attachment := &types.MessageAttachment{
URL: storageURL,
FileName: baseName,
FileType: ext,
FileSize: fileSize,
}
// Extract text content based on file type; errors are non-fatal.
if p.isTextFile(ext) {
if err := p.processTextFile(ctx, data, attachment); err != nil {
logger.Warnf(ctx, "text file processing failed: %v", err)
attachment.Content = fmt.Sprintf("<error><message>Failed to process text file</message><details>%v</details></error>", err)
}
} else if docparser.IsAudioFormat(ext) {
if err := p.processAudioFile(ctx, data, baseName, attachment, asrModelID); err != nil {
logger.Warnf(ctx, "audio transcription failed: %v, keeping placeholder", err)
attachment.Content = fmt.Sprintf("<error><message>Failed to transcribe audio file</message><details>%v</details></error>", err)
}
} else if docparser.IsSimpleFormat(ext) {
if err := p.processWithDocParser(ctx, data, baseName, ext, attachment, tenantID); err != nil {
logger.Warnf(ctx, "SimpleFormatReader failed: %v", err)
attachment.Content = fmt.Sprintf("<error><message>Failed to parse document</message><details>%v</details></error>", err)
}
} else {
if err := p.processWithDocumentReader(ctx, data, baseName, ext, attachment, tenantID); err != nil {
logger.Warnf(ctx, "DocumentReader failed: %v, keeping metadata only", err)
attachment.Content = fmt.Sprintf("<error><message>Failed to read document</message><details>%v</details></error>", err)
}
}
attachment.Content = common.CleanInvalidUTF8(attachment.Content)
logger.Infof(ctx, "attachment processed: fileName=%s, truncated=%v, contentLen=%d",
secutils.SanitizeForLog(baseName), attachment.IsTruncated, len(attachment.Content))
return attachment, nil
}
// isTextFile reports whether ext is a plain-text extension handled line-by-line.
func (p *AttachmentProcessor) isTextFile(ext string) bool {
return strings.Contains(textFileExtensions, ext)
}
// processTextFile reads plain-text content line by line, truncating at maxTextFileLines.
func (p *AttachmentProcessor) processTextFile(ctx context.Context, data []byte, attachment *types.MessageAttachment) error {
scanner := bufio.NewScanner(bytes.NewReader(data))
var lines []string
lineCount := 0
for scanner.Scan() {
lineCount++
if lineCount <= maxTextFileLines {
lines = append(lines, scanner.Text())
}
}
if err := scanner.Err(); err != nil {
return fmt.Errorf("failed to read file content: %w", err)
}
attachment.LineCount = lineCount
attachment.Content = strings.Join(lines, "\n")
if lineCount > maxTextFileLines {
attachment.IsTruncated = true
logger.Infof(ctx, "text file truncated: total=%d, kept=%d", lineCount, maxTextFileLines)
}
return nil
}
// processWithDocParser extracts content via SimpleFormatReader (md, csv, json, images, etc.).
func (p *AttachmentProcessor) processWithDocParser(
ctx context.Context,
data []byte,
fileName string,
fileType string,
attachment *types.MessageAttachment,
tenantID uint64,
) error {
reader := &docparser.SimpleFormatReader{}
result, err := reader.Read(ctx, &types.ReadRequest{
FileContent: data,
FileName: fileName,
FileType: fileType,
})
if err != nil {
return fmt.Errorf("SimpleFormatReader failed: %w", err)
}
// Resolve embedded image refs to storage URLs.
if len(result.ImageRefs) > 0 && p.imageResolver != nil {
updatedMarkdown, _, err := p.imageResolver.ResolveAndStore(ctx, result, p.fileService, tenantID)
if err != nil {
logger.Warnf(ctx, "image resolution failed: %v", err)
} else {
result.MarkdownContent = updatedMarkdown
}
}
p.applyLineTruncation(ctx, result.MarkdownContent, attachment)
return nil
}
// processAudioFile transcribes audio via ASR. Falls back to a placeholder when no ASR model is configured.
func (p *AttachmentProcessor) processAudioFile(
ctx context.Context,
data []byte,
fileName string,
attachment *types.MessageAttachment,
asrModelID string,
) error {
if asrModelID == "" || p.modelService == nil {
attachment.Content = fmt.Sprintf("<audio_file name=\"%s\" transcription=\"unsupported\" />", fileName)
logger.Infof(ctx, "no ASR model configured, keeping audio placeholder")
return nil
}
asrInstance, err := p.modelService.GetASRModel(ctx, asrModelID)
if err != nil {
return fmt.Errorf("failed to get ASR model: %w", err)
}
logger.Infof(ctx, "starting audio transcription: fileName=%s, size=%d", fileName, len(data))
res, err := asrInstance.Transcribe(ctx, data, fileName)
if err != nil {
return fmt.Errorf("audio transcription failed: %w", err)
}
transcript := res.Text
p.applyLineTruncation(ctx, transcript, attachment)
logger.Infof(ctx, "audio transcription done: textLen=%d", len(transcript))
return nil
}
// processWithDocumentReader extracts content from complex formats (pdf, docx, xlsx, etc.).
func (p *AttachmentProcessor) processWithDocumentReader(
ctx context.Context,
data []byte,
fileName string,
fileType string,
attachment *types.MessageAttachment,
tenantID uint64,
) error {
if p.documentReader == nil {
return fmt.Errorf("DocumentReader not configured")
}
normalizedType := strings.TrimPrefix(fileType, ".")
parserEngine := ""
if v := ctx.Value(types.ChatParserEngineContextKey); v != nil {
if s, ok := v.(string); ok {
parserEngine = s
}
}
overrides := getParserEngineOverridesFromContext(ctx)
// Engines that parse in this process (anydoc, MinerU, ...) are resolved
// through the registry so a chat attachment honours the same engine rules
// as an ingested document. Anything the registry cannot build here — a
// cloud engine whose credentials this path cannot resolve — falls back to
// the docreader, which is where every engine name went before.
reader, err := docparser.NewReader(ctx, parserEngine, normalizedType, false, docparser.ReaderDeps{
Overrides: overrides,
Remote: p.documentReader,
})
if err != nil {
logger.Warnf(ctx, "parser engine %q unusable for this attachment, using docreader: %v", parserEngine, err)
reader = p.documentReader
}
result, err := reader.Read(ctx, &types.ReadRequest{
FileContent: data,
FileName: fileName,
FileType: normalizedType,
ParserEngine: parserEngine,
ParserEngineOverrides: overrides,
})
if err != nil {
return fmt.Errorf("document parsing failed: %w", err)
}
// Resolve embedded image refs to storage URLs.
if len(result.ImageRefs) > 0 && p.imageResolver != nil {
updatedMarkdown, _, err := p.imageResolver.ResolveAndStore(ctx, result, p.fileService, tenantID)
if err != nil {
logger.Warnf(ctx, "image resolution failed: %v", err)
} else {
result.MarkdownContent = updatedMarkdown
}
}
p.applyLineTruncation(ctx, result.MarkdownContent, attachment)
return nil
}
// applyLineTruncation stores content into attachment, truncating at maxTextFileLines if needed.
func (p *AttachmentProcessor) applyLineTruncation(ctx context.Context, content string, attachment *types.MessageAttachment) {
lines := strings.Split(content, "\n")
lineCount := len(lines)
attachment.LineCount = lineCount
if lineCount > maxTextFileLines {
attachment.Content = strings.Join(lines[:maxTextFileLines], "\n")
attachment.IsTruncated = true
logger.Infof(ctx, "content truncated: total=%d, kept=%d", lineCount, maxTextFileLines)
} else {
attachment.Content = content
}
}
// isValidFileType reports whether fileName has a supported extension.
// Kept in sync with the frontend SUPPORTED_TYPES list in AttachmentUpload.vue.
func isValidFileType(fileName string) bool {
ext := strings.ToLower(filepath.Ext(fileName))
if ext == "" {
return false
}
ext = strings.TrimPrefix(ext, ".")
supportedTypes := []string{
// documents
"docx", "doc", "pdf", "ppt", "pptx", "epub", "mhtml",
// spreadsheets
"xlsx", "xls",
// text / markup
"md", "markdown", "txt", "csv", "json", "xml", "yaml", "yml", "log", "html",
// images
"jpg", "jpeg", "png", "gif", "bmp", "tiff", "webp",
// audio
"mp3", "wav", "m4a", "flac", "ogg", "aac",
}
for _, t := range supportedTypes {
if ext == t {
return true
}
}
return false
}
// getParserEngineOverridesFromContext returns parser engine overrides from tenant in context.
func getParserEngineOverridesFromContext(ctx context.Context) map[string]string {
if v := ctx.Value(types.TenantInfoContextKey); v != nil {
if tenant, ok := v.(*types.Tenant); ok && tenant != nil && tenant.ParserEngineConfig != nil {
return tenant.ParserEngineConfig.ToOverridesMap()
}
}
return nil
}
// DecodeBase64Attachment decodes a base64 attachment payload, stripping any data URI prefix.
// Tries Std, URL, RawStd, and RawURL encodings in order.
func DecodeBase64Attachment(data string) ([]byte, error) {
// Strip data URI prefix (e.g. "data:application/pdf;base64,")
if idx := strings.Index(data, ","); idx != -1 {
data = data[idx+1:]
}
data = strings.TrimSpace(data)
for _, enc := range []struct{ e *base64.Encoding }{
{base64.StdEncoding},
{base64.URLEncoding},
{base64.RawStdEncoding},
{base64.RawURLEncoding},
} {
if decoded, err := enc.e.DecodeString(data); err == nil {
return decoded, nil
}
}
return nil, fmt.Errorf("base64 decode failed: unrecognised encoding")
}
@@ -0,0 +1,140 @@
package session
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
// The stubs below implement only the methods ContinueStream reaches; embedding
// each interface keeps everything else nil-panicky so an un-stubbed call fails
// loudly rather than returning a zero value.
type stubSessionService struct {
interfaces.SessionService
}
func (s *stubSessionService) GetSession(_ context.Context, id string) (*types.Session, error) {
return &types.Session{ID: id, TenantID: 1}, nil
}
type stubMessageServiceForStream struct {
interfaces.MessageService
}
func (s *stubMessageServiceForStream) GetMessage(
_ context.Context, sessionID, messageID string,
) (*types.Message, error) {
return &types.Message{ID: messageID, SessionID: sessionID, RequestID: "req-1"}, nil
}
// stubStreamManager replays a fixed event list, mimicking a completed stream.
type stubStreamManager struct {
events []interfaces.StreamEvent
}
func (s *stubStreamManager) AppendEvent(
context.Context, string, string, interfaces.StreamEvent,
) error {
return nil
}
func (s *stubStreamManager) GetEvents(
_ context.Context, _, _ string, fromOffset int,
) ([]interfaces.StreamEvent, int, error) {
if fromOffset >= len(s.events) {
return nil, len(s.events), nil
}
return s.events[fromOffset:], len(s.events), nil
}
// completedAnswerStream is one assistant turn whose answer embeds a knowledge-base
// image, with the resource handle straddling two deltas as it does in production
// when the model-context decoder flushes mid-reference.
func completedAnswerStream() []interfaces.StreamEvent {
return []interfaces.StreamEvent{
{ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "The diagram ![fig](resource://xifDo7"},
{ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "NTSL300Lp1goVutw) shows the flow."},
{ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "", Done: true},
{ID: "refs-1", Type: types.ResponseTypeReferences, Data: map[string]interface{}{
"references": types.References{{
ID: "chunk-1",
Content: "figure ![f](" + testResourceHandle + ")",
ImageInfo: `[{"url":"` + testResourceHandle + `"}]`,
}},
}},
{ID: "complete-1", Type: types.ResponseTypeComplete, Done: true},
}
}
func newContinueStreamRouter(t *testing.T) *gin.Engine {
t.Helper()
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &Handler{
sessionService: &stubSessionService{},
messageService: &stubMessageServiceForStream{},
streamManager: &stubStreamManager{events: completedAnswerStream()},
fileService: &stubResourceFileService{},
}
r.GET("/sessions/continue-stream/:session_id", h.ContinueStream)
return r
}
func continueStream(t *testing.T, query string) (int, string) {
t.Helper()
recorder := httptest.NewRecorder()
newContinueStreamRouter(t).ServeHTTP(recorder, httptest.NewRequest(
http.MethodGet, "/sessions/continue-stream/sess1?message_id=msg1"+query, nil))
return recorder.Code, recorder.Body.String()
}
// Default behaviour: the SSE stream keeps carrying internal handles, which the
// WeKnora frontend resolves through the authenticated /files proxy.
func TestContinueStream_DefaultEmitsHandles(t *testing.T) {
code, body := continueStream(t, "")
require.Equal(t, http.StatusOK, code, body)
assert.Contains(t, body, testResourceHandle)
assert.NotContains(t, body, "cdn.example.com")
}
// With resource_urls=public the whole stream — answer text and the references
// payload — carries URLs a third-party app can load directly.
func TestContinueStream_PublicModeEmitsLoadableURLs(t *testing.T) {
code, body := continueStream(t, "&resource_urls=public")
require.Equal(t, http.StatusOK, code, body)
assert.NotContains(t, body, "resource://",
"no internal handle may reach a client that asked for public URLs")
assert.Contains(t, body, "https://cdn.example.com/signed.png")
// The handle was split across two answer deltas; the reassembled Markdown
// image must be intact rather than broken in half.
assert.Contains(t, body, `![fig](https://cdn.example.com/signed.png) shows the flow.`)
// The references payload carries both the chunk text and image_info.
assert.Contains(t, body, `figure ![f](https://cdn.example.com/signed.png)`)
assert.Contains(t, body, `[{\"url\":\"https://cdn.example.com/signed.png\"}]`)
// Every answer delta must precede the completion marker.
assert.Less(t, strings.LastIndex(body, `"response_type":"answer"`),
strings.Index(body, `"response_type":"complete"`))
}
func TestContinueStream_RejectsInvalidResourceURLMode(t *testing.T) {
code, body := continueStream(t, "&resource_urls=signed")
assert.Equal(t, http.StatusBadRequest, code, body)
assert.Contains(t, body, "resource_urls")
}
+559
View File
@@ -0,0 +1,559 @@
package session
import (
stderrors "errors"
"net/http"
"github.com/Tencent/WeKnora/internal/application/service"
"github.com/Tencent/WeKnora/internal/config"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/infrastructure/docparser"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// Handler handles all HTTP requests related to conversation sessions
type Handler struct {
messageService interfaces.MessageService // Service for managing messages
suggestionService interfaces.MessageSuggestionService
sessionService interfaces.SessionService // Service for managing sessions
streamManager interfaces.StreamManager // Manager for handling streaming responses
config *config.Config // Application configuration
knowledgebaseService interfaces.KnowledgeBaseService // Service for managing knowledge bases
customAgentService interfaces.CustomAgentService // Service for managing custom agents
tenantService interfaces.TenantService // Service for loading tenant (shared agent context)
agentShareService interfaces.AgentShareService // Service for resolving shared agents (KB scope in retrieval)
kbShareService interfaces.KBShareService // Service for resolving shared KB permissions
fileService interfaces.FileService // Service for file storage (image uploads)
resourceCatalog interfaces.ResourceCatalog
storageResolver interfaces.StorageBackendResolver
modelService interfaces.ModelService // Service for model management (VLM access)
attachmentProcessor *AttachmentProcessor // Processor for file attachments
temporaryDocuments interfaces.TemporaryDocumentService
// artifactCollector drains skill-generated files from the session sandbox
// after an agent turn completes. May be nil when the sandbox backend does
// not support artifact collection; handlers must check before using.
artifactCollector *service.ArtifactCollector
memoryService interfaces.MemoryService // Service for cross-session long-term memory
}
// NewHandler creates a new instance of Handler with all necessary dependencies
func NewHandler(
sessionService interfaces.SessionService,
messageService interfaces.MessageService,
suggestionService interfaces.MessageSuggestionService,
streamManager interfaces.StreamManager,
config *config.Config,
knowledgebaseService interfaces.KnowledgeBaseService,
customAgentService interfaces.CustomAgentService,
tenantService interfaces.TenantService,
agentShareService interfaces.AgentShareService,
kbShareService interfaces.KBShareService,
fileService interfaces.FileService,
resourceCatalog interfaces.ResourceCatalog,
storageResolver interfaces.StorageBackendResolver,
modelService interfaces.ModelService,
documentReader interfaces.DocumentReader,
imageResolver *docparser.ImageResolver,
temporaryDocuments interfaces.TemporaryDocumentService,
artifactCollector *service.ArtifactCollector,
memoryService interfaces.MemoryService,
) *Handler {
return &Handler{
sessionService: sessionService,
messageService: messageService,
suggestionService: suggestionService,
streamManager: streamManager,
config: config,
knowledgebaseService: knowledgebaseService,
customAgentService: customAgentService,
tenantService: tenantService,
agentShareService: agentShareService,
kbShareService: kbShareService,
fileService: fileService,
resourceCatalog: resourceCatalog,
storageResolver: storageResolver,
modelService: modelService,
temporaryDocuments: temporaryDocuments,
artifactCollector: artifactCollector,
memoryService: memoryService,
attachmentProcessor: NewAttachmentProcessor(
fileService,
documentReader,
imageResolver,
modelService,
),
}
}
// CreateSession godoc
// @Summary 创建会话
// @Description 创建新的对话会话
// @Tags 会话
// @Accept json
// @Produce json
// @Param request body CreateSessionRequest true "会话创建请求"
// @Success 201 {object} map[string]interface{} "创建的会话"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions [post]
func (h *Handler) CreateSession(c *gin.Context) {
ctx := c.Request.Context()
// Parse and validate the request body
var request CreateSessionRequest
if err := c.ShouldBindJSON(&request); err != nil {
logger.Error(ctx, "Failed to validate session creation parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
// Get tenant ID from context
tenantID, exists := c.Get(types.TenantIDContextKey.String())
if !exists {
logger.Error(ctx, "Failed to get tenant ID")
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
// Sessions are now knowledge-base-independent:
// - All configuration comes from custom agent at query time
// - Session only stores basic info (tenant ID, title, description)
logger.Infof(
ctx,
"Processing session creation request, tenant ID: %d",
tenantID,
)
// Create session object with base properties
createdSession := &types.Session{
TenantID: tenantID.(uint64),
Title: request.Title,
Description: types.SanitizeClientSessionDescription(request.Description, ""),
}
// Attach the calling user as the session owner when available.
// API-key callers scope sessions per external user when configured;
// otherwise they fall back to the synthetic tenant user.
if ownerID := types.SessionOwnerIDFromContext(ctx); ownerID != "" {
createdSession.UserID = ownerID
}
// Call service to create session
logger.Infof(ctx, "Calling session service to create session")
createdSession, err := h.sessionService.CreateSession(ctx, createdSession)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Return created session
logger.Infof(ctx, "Session created successfully, ID: %s", createdSession.ID)
c.JSON(http.StatusCreated, gin.H{
"success": true,
"data": createdSession,
})
}
// GetSession godoc
// @Summary 获取会话详情
// @Description 根据ID获取会话详情
// @Tags 会话
// @Accept json
// @Produce json
// @Param id path string true "会话ID"
// @Success 200 {object} map[string]interface{} "会话详情"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{id} [get]
func (h *Handler) GetSession(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start retrieving session")
// Get session ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Call service to get session details
logger.Infof(ctx, "Retrieving session, ID: %s", id)
session, err := h.sessionService.GetSession(ctx, id)
if err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "Session not found, ID: %s", id)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Return session data
logger.Infof(ctx, "Session retrieved successfully, ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": session,
})
}
// GetSessionsByTenant godoc
// @Summary 获取会话列表
// @Description 获取当前空间的会话列表,支持分页、关键字搜索、按来源/Agent 筛选
// @Tags 会话
// @Accept json
// @Produce json
// @Param page query int false "页码"
// @Param page_size query int false "每页数量"
// @Param keyword query string false "标题模糊搜索"
// @Param source query string false "来源过滤:web / embed / api / feishu / wechat / slack / ...(api、embed、IM 渠道需 Admin+)"
// @Param agent_id query string false "按 Agent 过滤(仅对 IM 会话生效)"
// @Success 200 {object} map[string]interface{} "会话列表"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions [get]
func (h *Handler) GetSessionsByTenant(c *gin.Context) {
ctx := c.Request.Context()
// Parse pagination parameters from query
var pagination types.Pagination
if err := c.ShouldBindQuery(&pagination); err != nil {
logger.Error(ctx, "Failed to parse pagination parameters", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
// Response items always include pin state and (when available) IM origin
// fields so the frontend can render pin icons / source badges without a
// second roundtrip. Unset filter params behave like "no filter".
result, err := h.sessionService.ListSessions(ctx, &types.SessionListQuery{
Keyword: c.Query("keyword"),
Source: c.Query("source"),
AgentID: c.Query("agent_id"),
Page: pagination.Page,
PageSize: pagination.PageSize,
})
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": result.Data,
"total": result.Total,
"page": result.Page,
"page_size": result.PageSize,
})
}
// UpdateSession godoc
// @Summary 更新会话
// @Description 更新会话属性
// @Tags 会话
// @Accept json
// @Produce json
// @Param id path string true "会话ID"
// @Param request body types.Session true "会话信息"
// @Success 200 {object} map[string]interface{} "更新后的会话"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{id} [put]
func (h *Handler) UpdateSession(c *gin.Context) {
ctx := c.Request.Context()
// Get session ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Verify tenant ID from context for authorization
tenantID, exists := c.Get(types.TenantIDContextKey.String())
if !exists {
logger.Error(ctx, "Failed to get tenant ID")
c.Error(errors.NewUnauthorizedError("Unauthorized"))
return
}
// Parse request body to session object
var session types.Session
if err := c.ShouldBindJSON(&session); err != nil {
logger.Error(ctx, "Failed to parse session data", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
session.ID = id
session.TenantID = tenantID.(uint64)
// Call service to update session
if err := h.sessionService.UpdateSession(ctx, &session); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "Session not found, ID: %s", id)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Reload session from database to return complete timestamps and stored fields
updatedSession, err := h.sessionService.GetSession(ctx, id)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Return updated session
logger.Infof(ctx, "Session updated successfully, ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": updatedSession,
})
}
// DeleteSession godoc
// @Summary 删除会话
// @Description 删除指定的会话
// @Tags 会话
// @Accept json
// @Produce json
// @Param id path string true "会话ID"
// @Success 200 {object} map[string]interface{} "删除成功"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{id} [delete]
func (h *Handler) DeleteSession(c *gin.Context) {
ctx := c.Request.Context()
// Get session ID from URL parameter
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Call service to delete session
if err := h.sessionService.DeleteSession(ctx, id); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "Session not found, ID: %s", id)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Return success message
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Session deleted successfully",
})
}
// ClearSessionMessages godoc
// @Summary 清空会话消息
// @Description 删除会话中的所有消息,同时清除 LLM 上下文和聊天历史知识库条目。会话本身保留。
// @Tags 会话
// @Accept json
// @Produce json
// @Param id path string true "会话ID"
// @Success 200 {object} map[string]interface{} "清空成功"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{id}/messages [delete]
func (h *Handler) ClearSessionMessages(c *gin.Context) {
ctx := c.Request.Context()
id := secutils.SanitizeForLog(c.Param("id"))
if id == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
logger.Infof(ctx, "Clearing all messages for session: %s", id)
if err := h.messageService.ClearSessionMessages(ctx, id); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "Session not found, ID: %s", id)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, map[string]interface{}{"session_id": id})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
logger.Infof(ctx, "Session messages cleared successfully, ID: %s", id)
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Session messages cleared successfully",
})
}
// batchDeleteRequest represents the request body for batch deleting sessions
type batchDeleteRequest struct {
IDs []string `json:"ids"`
DeleteAll bool `json:"delete_all"`
}
// BatchDeleteSessions godoc
// @Summary 批量删除会话
// @Description 根据ID列表批量删除对话会话,或设置 delete_all=true 删除当前空间的所有会话
// @Tags 会话
// @Accept json
// @Produce json
// @Param request body batchDeleteRequest true "批量删除请求"
// @Success 200 {object} map[string]interface{} "删除结果"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/batch [delete]
func (h *Handler) BatchDeleteSessions(c *gin.Context) {
ctx := c.Request.Context()
var req batchDeleteRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.Errorf(ctx, "Invalid batch delete request: %v", err)
c.Error(errors.NewBadRequestError("invalid request"))
return
}
if req.DeleteAll {
if err := h.sessionService.DeleteAllSessions(ctx); err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "All sessions deleted successfully",
})
return
}
if len(req.IDs) == 0 {
c.Error(errors.NewBadRequestError("ids are required when delete_all is false"))
return
}
// Sanitize all IDs
sanitizedIDs := make([]string, 0, len(req.IDs))
for _, id := range req.IDs {
sanitized := secutils.SanitizeForLog(id)
if sanitized != "" {
sanitizedIDs = append(sanitizedIDs, sanitized)
}
}
if len(sanitizedIDs) == 0 {
c.Error(errors.NewBadRequestError("no valid session IDs provided"))
return
}
if err := h.sessionService.BatchDeleteSessions(ctx, sanitizedIDs); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "No visible sessions found for batch delete")
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"message": "Sessions deleted successfully",
})
}
// PinSession godoc
// @Summary 置顶会话
// @Description 将指定会话置顶(用户维度)
// @Tags 会话
// @Produce json
// @Param session_id path string true "会话ID"
// @Success 200 {object} map[string]interface{} "置顶成功"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{session_id}/pin [post]
func (h *Handler) PinSession(c *gin.Context) {
h.setSessionPinned(c, true)
}
// UnpinSession godoc
// @Summary 取消置顶会话
// @Description 取消指定会话的置顶
// @Tags 会话
// @Produce json
// @Param id path string true "会话ID"
// @Success 200 {object} map[string]interface{} "取消置顶成功"
// @Failure 404 {object} errors.AppError "会话不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{id}/pin [delete]
func (h *Handler) UnpinSession(c *gin.Context) {
h.setSessionPinned(c, false)
}
func (h *Handler) setSessionPinned(c *gin.Context, pinned bool) {
ctx := c.Request.Context()
// POST and DELETE for /sessions/.../pin register under different wildcards
// (POST :session_id, DELETE :id — see router.go). Accept whichever is set.
rawID := c.Param("session_id")
if rawID == "" {
rawID = c.Param("id")
}
id := secutils.SanitizeForLog(rawID)
if id == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
rows, err := h.sessionService.SetSessionPinned(ctx, id, pinned)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": id,
"pinned": pinned,
})
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Zero rows means the session doesn't exist or isn't visible to this user;
// tell the client rather than reporting success.
if rows == 0 {
c.Error(errors.NewNotFoundError(errors.ErrSessionNotFound.Error()))
return
}
c.JSON(http.StatusOK, gin.H{
"success": true,
"is_pinned": pinned,
})
}
+503
View File
@@ -0,0 +1,503 @@
package session
import (
"context"
"fmt"
"strings"
"time"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// convertImageAttachments converts ImageAttachment slice to types.MessageImages
func convertImageAttachments(items []ImageAttachment) types.MessageImages {
if len(items) == 0 {
return nil
}
result := make(types.MessageImages, len(items))
for i, item := range items {
result[i] = types.MessageImage{
URL: item.URL,
Caption: item.Caption,
}
}
return result
}
// extractImageURLsAndOCRText extracts image references and concatenated analysis text.
// For LLM consumption it prefers the raw Data (data URI) when available so that
// image_resolve can skip the disk round-trip; falls back to the storage URL otherwise.
func extractImageURLsAndOCRText(images []ImageAttachment) (urls []string, ocrText string) {
if len(images) == 0 {
return nil, ""
}
urls = make([]string, 0, len(images))
var parts []string
for _, img := range images {
switch {
case img.Data != "":
urls = append(urls, img.Data)
case img.URL != "":
urls = append(urls, img.URL)
}
if img.Caption != "" {
parts = append(parts, img.Caption)
}
}
if len(parts) > 0 {
ocrText = strings.Join(parts, "\n")
}
return
}
// convertMentionedItems converts MentionedItemRequest slice to types.MentionedItems
func convertMentionedItems(items []MentionedItemRequest) types.MentionedItems {
if len(items) == 0 {
return nil
}
result := make(types.MentionedItems, len(items))
for i, item := range items {
result[i] = types.MentionedItem{
ID: item.ID,
Name: item.Name,
Type: item.Type,
KBType: item.KBType,
KBID: item.KBID,
KBName: item.KBName,
ServiceID: item.ServiceID,
SkillName: item.SkillName,
}
}
return result
}
func tagScopesFromMentionedItems(items []MentionedItemRequest) []types.TagScope {
byKB := make(map[string][]string)
seen := make(map[string]map[string]bool)
for _, item := range items {
if item.Type != "tag" || item.ID == "" || item.KBID == "" {
continue
}
if seen[item.KBID] == nil {
seen[item.KBID] = make(map[string]bool)
}
if seen[item.KBID][item.ID] {
continue
}
seen[item.KBID][item.ID] = true
byKB[item.KBID] = append(byKB[item.KBID], item.ID)
}
scopes := make([]types.TagScope, 0, len(byKB))
for kbID, tagIDs := range byKB {
scopes = append(scopes, types.TagScope{KnowledgeBaseID: kbID, TagIDs: tagIDs})
}
return scopes
}
// orphanTagIDsForScope returns tag IDs from the request that are not already
// covered by scoped mentions.
func orphanTagIDsForScope(tagIDs []string, scopes []types.TagScope) []string {
if len(tagIDs) == 0 {
return nil
}
covered := make(map[string]bool)
for _, scope := range scopes {
for _, id := range scope.TagIDs {
covered[id] = true
}
}
orphan := make([]string, 0, len(tagIDs))
for _, id := range tagIDs {
if id != "" && !covered[id] {
orphan = append(orphan, id)
}
}
return orphan
}
// validateUnscopedTagIDs rejects bare tag_ids that cannot be attached to a KB.
func validateUnscopedTagIDs(orphan []string, kbIDs []string) error {
if len(orphan) == 0 {
return nil
}
if len(kbIDs) == 1 {
return nil
}
return fmt.Errorf("tag_ids must be scoped via mentioned_items or exactly one knowledge_base_id")
}
// mergeTagScopesFromRequestIDs supplements tag scopes built from mentioned_items
// with bare tag_ids when the client did not send kb_id on each tag mention.
// Orphan tag IDs are attached to the sole knowledge_base_id when unambiguous.
func mergeTagScopesFromRequestIDs(scopes []types.TagScope, tagIDs, kbIDs []string) []types.TagScope {
orphan := orphanTagIDsForScope(tagIDs, scopes)
if len(orphan) == 0 {
return scopes
}
if len(kbIDs) != 1 {
return scopes
}
kbID := kbIDs[0]
for i, scope := range scopes {
if scope.KnowledgeBaseID == kbID {
merged := append(append([]string(nil), scope.TagIDs...), orphan...)
scopes[i].TagIDs = dedupRequestStrings(merged)
return scopes
}
}
return append(scopes, types.TagScope{KnowledgeBaseID: kbID, TagIDs: dedupRequestStrings(orphan)})
}
func mentionedIDsByType(items []MentionedItemRequest, itemType string) []string {
seen := make(map[string]bool)
result := make([]string, 0)
for _, item := range items {
if item.Type != itemType || item.ID == "" || seen[item.ID] {
continue
}
seen[item.ID] = true
result = append(result, item.ID)
}
return result
}
func dedupRequestStrings(values []string) []string {
seen := make(map[string]bool, len(values))
result := make([]string, 0, len(values))
for _, value := range values {
if value == "" || seen[value] {
continue
}
seen[value] = true
result = append(result, value)
}
return result
}
// setSSEHeaders sets the standard Server-Sent Events headers
func setSSEHeaders(c *gin.Context) {
c.Header("Content-Type", "text/event-stream")
c.Header("Cache-Control", "no-cache")
c.Header("Connection", "keep-alive")
c.Header("X-Accel-Buffering", "no")
}
// buildStreamResponse constructs a StreamResponse from a StreamEvent
func buildStreamResponse(evt interfaces.StreamEvent, requestID string) *types.StreamResponse {
response := &types.StreamResponse{
ID: requestID,
ResponseType: evt.Type,
Content: evt.Content,
Done: evt.Done,
Data: evt.Data,
Usage: evt.Usage,
}
// Extract session_id and assistant_message_id for agent_query events
if evt.Type == types.ResponseTypeAgentQuery {
if sid, ok := evt.Data["session_id"].(string); ok {
response.SessionID = sid
}
if amid, ok := evt.Data["assistant_message_id"].(string); ok {
response.AssistantMessageID = amid
}
}
// Special handling for references event
if evt.Type == types.ResponseTypeReferences {
refsData := evt.Data["references"]
if refsData == nil {
return response
}
if refs, ok := refsData.(types.References); ok {
response.KnowledgeReferences = refs
} else if refs, ok := refsData.([]*types.SearchResult); ok {
response.KnowledgeReferences = types.References(refs)
} else if refs, ok := refsData.([]interface{}); ok {
// Handle case where data was serialized/deserialized (e.g., from Redis)
searchResults := make([]*types.SearchResult, 0, len(refs))
for _, ref := range refs {
if refMap, ok := ref.(map[string]interface{}); ok {
searchResults = append(searchResults, searchResultFromMap(refMap))
}
}
response.KnowledgeReferences = types.References(searchResults)
}
}
return response
}
// sendCompletionEvent sends a final completion event to the client
// NOTE: This is now a no-op because:
// 1. The 'complete' event from handleComplete already signals stream completion
// 2. Sending an extra empty 'answer' event with done:true causes frontend issues
// (multiple done events can confuse state management)
//
// The frontend should use 'complete' response_type to detect stream completion
func sendCompletionEvent(c *gin.Context, requestID string) {
// Intentionally empty - completion is signaled by the 'complete' event
// which is already sent before this function is called
}
// createAgentQueryEvent creates a standard agent query event.
// It carries the persisted user/assistant timestamps so the live UI can
// display the same created_at that history reload will return.
func createAgentQueryEvent(
sessionID, assistantMessageID, userMessageID string,
userCreatedAt, assistantCreatedAt time.Time,
) interfaces.StreamEvent {
data := map[string]interface{}{
"session_id": sessionID,
"assistant_message_id": assistantMessageID,
}
if userMessageID != "" {
data["user_message_id"] = userMessageID
}
if !userCreatedAt.IsZero() {
data["user_created_at"] = userCreatedAt.UTC().Format(time.RFC3339Nano)
}
if !assistantCreatedAt.IsZero() {
data["assistant_created_at"] = assistantCreatedAt.UTC().Format(time.RFC3339Nano)
}
return interfaces.StreamEvent{
ID: fmt.Sprintf("query-%d", time.Now().UnixNano()),
Type: types.ResponseTypeAgentQuery,
Content: "",
Done: true,
Timestamp: time.Now(),
Data: data,
}
}
// createUserMessage creates a user message and returns the created message.
func (h *Handler) createUserMessage(ctx context.Context, sessionID, query, requestID string, mentionedItems types.MentionedItems, images types.MessageImages, attachments types.MessageAttachments, channel string, attribution *types.SuggestionAttribution) (*types.Message, error) {
return h.messageService.CreateMessage(ctx, &types.Message{
SessionID: sessionID,
Role: "user",
Content: query,
RequestID: requestID,
CreatedAt: time.Now(),
IsCompleted: true,
MentionedItems: mentionedItems,
Images: images,
Attachments: attachments,
Channel: channel,
ExecutionContext: types.MessageExecutionContext{SuggestionAttribution: attribution},
})
}
// createAssistantMessage creates an assistant message
func (h *Handler) createAssistantMessage(ctx context.Context, assistantMessage *types.Message) (*types.Message, error) {
assistantMessage.CreatedAt = time.Now()
return h.messageService.CreateMessage(ctx, assistantMessage)
}
// setupStreamHandler creates and subscribes a stream handler
func (h *Handler) setupStreamHandler(
ctx context.Context,
sessionID, assistantMessageID, requestID string,
tenantID uint64,
receivedAt time.Time,
assistantMessage *types.Message,
eventBus *event.EventBus,
) *AgentStreamHandler {
streamHandler := NewAgentStreamHandler(
ctx, sessionID, assistantMessageID, requestID, tenantID, receivedAt,
assistantMessage, h.streamManager, eventBus, h.artifactCollector,
)
streamHandler.Subscribe()
return streamHandler
}
// setupStopEventHandler registers a stop event handler
func (h *Handler) setupStopEventHandler(
eventBus *event.EventBus,
sessionID string,
sessionTenantID uint64,
assistantMessage *types.Message,
cancel context.CancelFunc,
) {
eventBus.On(event.EventStop, func(ctx context.Context, evt event.Event) error {
logger.Infof(ctx, "Received stop event, cancelling async operations for session: %s", sessionID)
cancel()
// Preserve whatever has been streamed so far; do not overwrite Content.
// Use session's tenant for message update (ctx may have effectiveTenantID when using shared agent).
// Use WithoutCancel so the GORM UPDATE survives the upcoming ctx.Done triggered by cancel()/client disconnect.
updateCtx := context.WithValue(
context.WithoutCancel(ctx),
types.TenantIDContextKey, sessionTenantID,
)
h.completeAssistantMessage(updateCtx, assistantMessage, "", "") // empty query: stopped conversations are not indexed
return nil
})
}
// stopWatcherMaxDuration bounds the lifetime of a stop watcher as an
// anti-leak backstop. Normally the watcher exits well before this on a
// terminal stream event; this only guards pathological streams that never
// emit a terminal marker.
const stopWatcherMaxDuration = 2 * time.Hour
// startStopWatcher polls the stream for a user-requested stop event
// independently of the client's SSE connection.
//
// Background: the original design only detected the stop marker inside
// handleAgentEventsForSSE, which is bound to the request context. Once the
// client closes the SSE stream (common for API-Key / programmatic callers that
// close the stream before POSTing /stop), that loop returns and nothing
// converts the stop marker (written to the shared StreamManager by
// StopSession) into a context cancellation — so generation keeps running to
// completion even though /stop returned success.
//
// The watcher is intentionally self-terminating rather than tied to the QA
// service call returning: KnowledgeQA (quick answer) returns immediately while
// the actual token stream runs in a background goroutine, whereas AgentQA
// (smart reasoning) blocks until done. Keying teardown off the call return
// would therefore tear the watcher down before quick-answer streaming even
// starts. Instead it exits when it observes a terminal stream event
// (complete, or a stream-level error), on stop, or after a safety timeout.
func (h *Handler) startStopWatcher(
ctx context.Context,
sessionID, assistantMessageID string,
eventBus *event.EventBus,
) {
go func() {
watchCtx, cancel := context.WithTimeout(ctx, stopWatcherMaxDuration)
defer cancel()
ticker := time.NewTicker(300 * time.Millisecond)
defer ticker.Stop()
offset := 0
for {
select {
case <-watchCtx.Done():
return
case <-ticker.C:
events, newOffset, err := h.streamManager.GetEvents(watchCtx, sessionID, assistantMessageID, offset)
if err != nil {
// Transient read error (e.g. Redis blip); retry next tick.
continue
}
offset = newOffset
for _, evt := range events {
switch {
case evt.Type == types.ResponseType(event.EventStop):
logger.Infof(watchCtx,
"Stop watcher detected stop event, cancelling generation for session=%s, message=%s",
sessionID, assistantMessageID)
eventBus.Emit(watchCtx, event.Event{
Type: event.EventStop,
SessionID: sessionID,
Data: event.StopData{
SessionID: sessionID,
MessageID: assistantMessageID,
Reason: "user_requested",
},
})
return
case evt.Type == types.ResponseTypeComplete:
// Generation finished normally; nothing left to stop.
return
case evt.Type == types.ResponseTypeError && evt.Done:
// Stream-level (terminal) error; generation has ended.
return
}
}
}
}
}()
}
// writeAgentQueryEvent writes an agent query event to the stream manager
func (h *Handler) writeAgentQueryEvent(
ctx context.Context,
sessionID, userMessageID string,
userCreatedAt time.Time,
assistantMessage *types.Message,
) {
assistantMessageID := ""
var assistantCreatedAt time.Time
if assistantMessage != nil {
assistantMessageID = assistantMessage.ID
assistantCreatedAt = assistantMessage.CreatedAt
}
agentQueryEvent := createAgentQueryEvent(
sessionID, assistantMessageID, userMessageID, userCreatedAt, assistantCreatedAt,
)
if err := h.streamManager.AppendEvent(ctx, sessionID, assistantMessageID, agentQueryEvent); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": sessionID,
"message_id": assistantMessageID,
})
// Non-fatal error, continue
}
}
// getRequestID gets the request ID from gin context
func getRequestID(c *gin.Context) string {
return c.GetString(types.RequestIDContextKey.String())
}
// Helper function for type assertion with default value
func getString(m map[string]interface{}, key string) string {
if val, ok := m[key].(string); ok {
return val
}
return ""
}
func getFloat64(m map[string]interface{}, key string) float64 {
if val, ok := m[key].(float64); ok {
return val
}
if val, ok := m[key].(int); ok {
return float64(val)
}
return 0.0
}
// searchResultFromMap rebuilds a *types.SearchResult from a map that went
// through JSON/Redis serialization, preserving all fields including metadata.
func searchResultFromMap(refMap map[string]interface{}) *types.SearchResult {
sr := &types.SearchResult{
ID: getString(refMap, "id"),
Content: getString(refMap, "content"),
KnowledgeID: getString(refMap, "knowledge_id"),
ChunkIndex: int(getFloat64(refMap, "chunk_index")),
KnowledgeTitle: getString(refMap, "knowledge_title"),
StartAt: int(getFloat64(refMap, "start_at")),
EndAt: int(getFloat64(refMap, "end_at")),
Seq: int(getFloat64(refMap, "seq")),
Score: getFloat64(refMap, "score"),
ChunkType: getString(refMap, "chunk_type"),
ParentChunkID: getString(refMap, "parent_chunk_id"),
ImageInfo: getString(refMap, "image_info"),
KnowledgeFilename: getString(refMap, "knowledge_filename"),
KnowledgeSource: getString(refMap, "knowledge_source"),
KnowledgeDescription: getString(refMap, "knowledge_description"),
KnowledgeBaseID: getString(refMap, "knowledge_base_id"),
}
if meta, ok := refMap["metadata"].(map[string]interface{}); ok {
metadata := make(map[string]string)
for k, v := range meta {
if strVal, ok := v.(string); ok {
metadata[k] = strVal
}
}
sr.Metadata = metadata
}
return sr
}
// createDefaultSummaryConfig and fillSummaryConfigDefaults used to build
// per-session SummaryConfig from tenant-level ConversationConfig + config.yaml
// defaults. Both helpers became unreachable when the chat pipeline moved to
// CustomAgent (builtin-quick-answer / smart-reasoning) and the tenant-level
// ConversationConfig field was removed; deleting them avoids the only
// remaining references to that defunct path.
+133
View File
@@ -0,0 +1,133 @@
package session
import (
"encoding/json"
"testing"
"time"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestTagScopesFromMentionedItems(t *testing.T) {
scopes := tagScopesFromMentionedItems([]MentionedItemRequest{
{Type: "tag", ID: "tag-1", KBID: "kb-1"},
{Type: "tag", ID: "tag-2", KBID: "kb-1"},
{Type: "tag", ID: "tag-3", KBID: "kb-2"},
{Type: "tag", ID: "orphan", KBID: ""},
})
assert.Len(t, scopes, 2)
byKB := make(map[string][]string)
for _, scope := range scopes {
byKB[scope.KnowledgeBaseID] = scope.TagIDs
}
assert.ElementsMatch(t, []string{"tag-1", "tag-2"}, byKB["kb-1"])
assert.Equal(t, []string{"tag-3"}, byKB["kb-2"])
}
func TestMergeTagScopesFromRequestIDs_SingleKB(t *testing.T) {
scopes := mergeTagScopesFromRequestIDs(
[]types.TagScope{{KnowledgeBaseID: "kb-1", TagIDs: []string{"tag-1"}}},
[]string{"tag-2"},
[]string{"kb-1"},
)
assert.Len(t, scopes, 1)
assert.ElementsMatch(t, []string{"tag-1", "tag-2"}, scopes[0].TagIDs)
}
func TestMergeTagScopesFromRequestIDs_OrphanWithSingleKB(t *testing.T) {
scopes := mergeTagScopesFromRequestIDs(nil, []string{"tag-9"}, []string{"kb-1"})
assert.Len(t, scopes, 1)
assert.Equal(t, "kb-1", scopes[0].KnowledgeBaseID)
assert.Equal(t, []string{"tag-9"}, scopes[0].TagIDs)
}
func TestMergeTagScopesFromRequestIDs_AmbiguousKBIgnored(t *testing.T) {
scopes := mergeTagScopesFromRequestIDs(nil, []string{"tag-9"}, []string{"kb-1", "kb-2"})
assert.Empty(t, scopes)
}
func TestValidateUnscopedTagIDs(t *testing.T) {
assert.NoError(t, validateUnscopedTagIDs(nil, nil))
assert.NoError(t, validateUnscopedTagIDs(nil, []string{"kb-1", "kb-2"}))
assert.NoError(t, validateUnscopedTagIDs([]string{"tag-9"}, []string{"kb-1"}))
assert.Error(t, validateUnscopedTagIDs([]string{"tag-9"}, []string{"kb-1", "kb-2"}))
assert.Error(t, validateUnscopedTagIDs([]string{"tag-9"}, nil))
}
// TestSearchResultFromMap_RoundTrip simulates the Redis round trip: a
// *types.SearchResult is serialized to JSON, deserialized into a generic map,
// and rebuilt via searchResultFromMap. All fields, including metadata, must
// survive.
func TestSearchResultFromMap_RoundTrip(t *testing.T) {
original := &types.SearchResult{
ID: "chunk-1",
Content: "first part\nsecond part",
KnowledgeID: "knowledge-1",
ChunkIndex: 3,
KnowledgeTitle: "title",
StartAt: 10,
EndAt: 20,
Seq: 2,
Score: 4.5,
ChunkType: "text",
ParentChunkID: "parent-1",
ImageInfo: `[{"url":"cdn.example.com"}]`,
KnowledgeFilename: "doc.txt",
KnowledgeSource: "upload",
KnowledgeDescription: "desc",
KnowledgeBaseID: "kb-1",
Metadata: map[string]string{"page": "3"},
}
raw, err := json.Marshal(original)
require.NoError(t, err)
var refMap map[string]interface{}
require.NoError(t, json.Unmarshal(raw, &refMap))
got := searchResultFromMap(refMap)
assert.Equal(t, original.ID, got.ID)
assert.Equal(t, original.Content, got.Content)
assert.Equal(t, original.KnowledgeID, got.KnowledgeID)
assert.Equal(t, original.ChunkIndex, got.ChunkIndex)
assert.Equal(t, original.KnowledgeTitle, got.KnowledgeTitle)
assert.Equal(t, original.StartAt, got.StartAt)
assert.Equal(t, original.EndAt, got.EndAt)
assert.Equal(t, original.Seq, got.Seq)
assert.Equal(t, original.Score, got.Score)
assert.Equal(t, original.ChunkType, got.ChunkType)
assert.Equal(t, original.ParentChunkID, got.ParentChunkID)
assert.Equal(t, original.ImageInfo, got.ImageInfo)
assert.Equal(t, original.KnowledgeFilename, got.KnowledgeFilename)
assert.Equal(t, original.KnowledgeSource, got.KnowledgeSource)
assert.Equal(t, original.KnowledgeDescription, got.KnowledgeDescription)
assert.Equal(t, original.KnowledgeBaseID, got.KnowledgeBaseID)
assert.Equal(t, original.Metadata, got.Metadata)
}
func TestCreateAgentQueryEventIncludesPersistedTimestamps(t *testing.T) {
userAt := time.Date(2026, 1, 2, 3, 4, 5, 0, time.UTC)
assistantAt := time.Date(2026, 1, 2, 3, 4, 6, 0, time.UTC)
evt := createAgentQueryEvent("sess-1", "asst-1", "user-1", userAt, assistantAt)
assert.Equal(t, types.ResponseTypeAgentQuery, evt.Type)
assert.Equal(t, "sess-1", evt.Data["session_id"])
assert.Equal(t, "asst-1", evt.Data["assistant_message_id"])
assert.Equal(t, "user-1", evt.Data["user_message_id"])
assert.Equal(t, userAt.Format(time.RFC3339Nano), evt.Data["user_created_at"])
assert.Equal(t, assistantAt.Format(time.RFC3339Nano), evt.Data["assistant_created_at"])
}
func TestCreateAgentQueryEventOmitsZeroTimestamps(t *testing.T) {
evt := createAgentQueryEvent("sess-1", "asst-1", "", time.Time{}, time.Time{})
_, hasUserID := evt.Data["user_message_id"]
_, hasUserAt := evt.Data["user_created_at"]
_, hasAssistantAt := evt.Data["assistant_created_at"]
assert.False(t, hasUserID)
assert.False(t, hasUserAt)
assert.False(t, hasAssistantAt)
}
+169
View File
@@ -0,0 +1,169 @@
package session
import (
"context"
"encoding/base64"
"fmt"
"strings"
filesvc "github.com/Tencent/WeKnora/internal/application/service/file"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/google/uuid"
)
const (
maxImageSize = 10 << 20 // 10MB per image
maxImagesCount = 5
)
// saveImageAttachments decodes base64 images from the request and saves them to
// storage. The images slice is mutated in place: URL is populated.
// This is always called when images are present. VLM analysis is handled
// separately (either in the pipeline rewrite step for RAG paths, or via
// analyzeImageAttachments for pure chat paths with non-vision models).
func (h *Handler) saveImageAttachments(ctx context.Context, images []ImageAttachment, tenantID uint64, storageProvider string) error {
if len(images) == 0 {
return nil
}
if len(images) > maxImagesCount {
return fmt.Errorf("too many images, max %d", maxImagesCount)
}
fileSvc := h.resolveImageFileService(ctx, storageProvider)
for i := range images {
img := &images[i]
if img.Data == "" {
continue
}
imgBytes, ext, err := decodeDataURI(img.Data)
if err != nil {
return fmt.Errorf("decode image %d: %w", i, err)
}
if len(imgBytes) > maxImageSize {
return fmt.Errorf("image %d too large (%d bytes, max %d)", i, len(imgBytes), maxImageSize)
}
storedName := fmt.Sprintf("chat-images/%s%s", uuid.New().String(), ext)
fileURL, err := fileSvc.SaveBytes(ctx, imgBytes, tenantID, storedName, false)
if err != nil {
return fmt.Errorf("save image %d: %w", i, err)
}
img.URL = fileURL
}
return nil
}
// analyzeImageAttachments runs VLM analysis on saved images and populates Caption.
// Used as a fallback for pure chat paths where the pipeline rewrite step won't run.
// For RAG paths, image analysis is handled in the pipeline rewrite step instead.
func (h *Handler) analyzeImageAttachments(ctx context.Context, images []ImageAttachment, vlmModelID string, userQuery string) {
if len(images) == 0 || vlmModelID == "" {
return
}
vlmModel, err := h.modelService.GetVLMModel(ctx, vlmModelID)
if err != nil {
logger.Warnf(ctx, "No VLM model available for image analysis, skipping: %v", err)
return
}
for i := range images {
img := &images[i]
if img.Data == "" {
continue
}
imgBytes, _, decErr := decodeDataURI(img.Data)
if decErr != nil {
logger.Warnf(ctx, "Failed to decode image %d for VLM analysis: %v", i, decErr)
continue
}
prompt := buildImageAnalysisPrompt(userQuery)
analysis, analysisErr := vlmModel.Predict(ctx, [][]byte{imgBytes}, prompt)
if analysisErr != nil {
logger.Warnf(ctx, "VLM analysis failed for image %d: %v", i, analysisErr)
} else {
img.Caption = analysis
}
}
}
// buildImageAnalysisPrompt generates a context-aware VLM prompt based on the
// user's question. Instead of doing generic OCR + Caption separately, we do a
// single analysis call that is tailored to the user's intent.
func buildImageAnalysisPrompt(userQuery string) string {
if strings.TrimSpace(userQuery) == "" {
return "请分析这张图片的内容。如果包含文字,请提取关键文字信息;如果是自然图片,请描述其主要内容。用简洁的中文回答。"
}
return fmt.Sprintf(
"用户的问题是:%s\n\n请分析图片中与用户问题相关的内容。"+
"如果图片包含文字/文档/表格,请提取与问题相关的关键信息。"+
"如果是自然图片/截图/图表,请描述与问题相关的视觉内容。"+
"用简洁的中文回答,只输出分析结果。",
userQuery,
)
}
func decodeDataURI(dataURI string) ([]byte, string, error) {
if !strings.HasPrefix(dataURI, "data:") {
return nil, "", fmt.Errorf("not a data URI")
}
idx := strings.Index(dataURI, ";base64,")
if idx < 0 {
return nil, "", fmt.Errorf("unsupported data URI encoding (expected base64)")
}
mimeType := dataURI[5:idx]
decoded, err := base64.StdEncoding.DecodeString(dataURI[idx+8:])
if err != nil {
return nil, "", fmt.Errorf("base64 decode: %w", err)
}
ext := mimeToExt(mimeType)
return decoded, ext, nil
}
func mimeToExt(mime string) string {
switch strings.ToLower(mime) {
case "image/png":
return ".png"
case "image/jpeg":
return ".jpg"
case "image/gif":
return ".gif"
case "image/webp":
return ".webp"
default:
return ".png"
}
}
func (h *Handler) resolveImageFileService(ctx context.Context, storageProvider string) interfaces.FileService {
tenant, _ := ctx.Value(types.TenantInfoContextKey).(*types.Tenant)
if tenant == nil {
return h.fileService
}
if h.storageResolver != nil {
svc, resolvedProvider, err := h.storageResolver.ResolveFileService(ctx, tenant, "", storageProvider, "")
if err == nil && svc != nil {
logger.Infof(ctx, "[image-storage] using storage instance provider=%s for image uploads", resolvedProvider)
return svc
}
if err != nil {
logger.Warnf(ctx, "[image-storage] failed to resolve storage instance for provider=%s: %v", storageProvider, err)
}
}
if strings.TrimSpace(storageProvider) == "" || tenant.StorageEngineConfig == nil {
return h.fileService
}
svc, resolvedProvider, err := filesvc.NewFileServiceFromStorageConfig(storageProvider, tenant.StorageEngineConfig, "")
if err != nil {
logger.Warnf(ctx, "[image-storage] failed to create %s file service: %v, fallback to default", storageProvider, err)
return h.fileService
}
logger.Infof(ctx, "[image-storage] using provider=%s for image uploads", resolvedProvider)
return svc
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,62 @@
package session
import (
"encoding/base64"
"strings"
"testing"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func encodedAttachment(data string, declaredSize int64) AttachmentUpload {
return AttachmentUpload{
Data: base64.StdEncoding.EncodeToString([]byte(data)),
FileName: "file.txt",
FileSize: declaredSize,
}
}
func TestDecodeAndValidateAttachmentUploadsUsesActualBytes(t *testing.T) {
uploads := []AttachmentUpload{encodedAttachment("123456", 1)}
_, err := decodeAndValidateAttachmentUploads(uploads, 5, 5, 100)
require.Error(t, err)
assert.Contains(t, err.Error(), "exceeds size limit")
}
func TestDecodeAndValidateAttachmentUploadsBoundsCountAndTotal(t *testing.T) {
t.Run("count", func(t *testing.T) {
uploads := make([]AttachmentUpload, 6)
for i := range uploads {
uploads[i] = encodedAttachment("x", 1)
}
_, err := decodeAndValidateAttachmentUploads(uploads, 5, 10, 100)
require.Error(t, err)
assert.Contains(t, err.Error(), "at most 5")
})
t.Run("total", func(t *testing.T) {
uploads := []AttachmentUpload{
encodedAttachment(strings.Repeat("a", 6), 6),
encodedAttachment(strings.Repeat("b", 6), 6),
}
_, err := decodeAndValidateAttachmentUploads(uploads, 5, 10, 10)
require.Error(t, err)
assert.Contains(t, err.Error(), "total request limit")
})
}
func TestDecodeAndValidateAttachmentUploadsReturnsDecodedBytes(t *testing.T) {
decoded, err := decodeAndValidateAttachmentUploads(
[]AttachmentUpload{encodedAttachment("hello", 999)},
5,
10,
10,
)
require.NoError(t, err)
require.Len(t, decoded, 1)
assert.Equal(t, []byte("hello"), decoded[0])
}
@@ -0,0 +1,130 @@
package session
import (
"context"
"sync"
"time"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/types"
)
// quickAnswerTimelineTools are the stages a fast-answer turn draws on its
// timeline. A turn that retrieved nothing still reports the search it ran, so
// the set is what decides whether the step survives a reload — not whether it
// produced citations. Keep in sync with RAG_TIMELINE_TOOL_NAMES on the frontend.
var quickAnswerTimelineTools = map[string]struct{}{
"query_understand": {},
"knowledge_search": {},
"attachment_parsing": {},
"image_analysis": {},
}
// quickAnswerTimelineRecorder persists the fast-answer pipeline's timeline into
// the assistant message's AgentSteps.
//
// Reconstructing the timeline from knowledge_references only works for turns
// that cited something: a turn that searched and found nothing, or that only
// parsed an attachment, came back from history with no steps at all and lost
// the timeline it had while streaming.
type quickAnswerTimelineRecorder struct {
mu sync.Mutex
msg *types.Message
pending map[string]map[string]any
startedAt map[string]time.Time
}
// registerQuickAnswerTimelineRecorder subscribes to the pipeline's tool events
// for the lifetime of one fast-answer turn. Agent mode is excluded on purpose:
// its own steps are written by the agent stream handler, which overwrites
// AgentSteps wholesale.
func registerQuickAnswerTimelineRecorder(bus *event.EventBus, msg *types.Message) {
if bus == nil || msg == nil {
return
}
rec := &quickAnswerTimelineRecorder{
msg: msg,
pending: make(map[string]map[string]any),
startedAt: make(map[string]time.Time),
}
bus.On(event.EventAgentToolCall, rec.onToolCall)
bus.On(event.EventAgentToolResult, rec.onToolResult)
}
func (r *quickAnswerTimelineRecorder) onToolCall(_ context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentToolCallData)
if !ok || !isQuickAnswerTimelineTool(data.ToolName) || data.ToolCallID == "" {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
r.pending[data.ToolCallID] = data.Arguments
r.startedAt[data.ToolCallID] = time.Now()
return nil
}
// onToolResult is where the step is persisted. Waiting for the result keeps a
// half-finished stage out of history: a stage still running when the turn is
// stopped would come back from a reload looking like it had completed.
func (r *quickAnswerTimelineRecorder) onToolResult(_ context.Context, evt event.Event) error {
data, ok := evt.Data.(event.AgentToolResultData)
if !ok || !isQuickAnswerTimelineTool(data.ToolName) || data.ToolCallID == "" {
return nil
}
r.mu.Lock()
defer r.mu.Unlock()
args := r.pending[data.ToolCallID]
delete(r.pending, data.ToolCallID)
started, hasStart := r.startedAt[data.ToolCallID]
delete(r.startedAt, data.ToolCallID)
duration := data.Duration
if duration == 0 && hasStart {
duration = time.Since(started).Milliseconds()
}
appendQuickAnswerToolCall(r.msg, types.ToolCall{
ID: types.PipelineToolCallIDPrefix + data.ToolCallID,
Name: data.ToolName,
Args: args,
Result: &types.ToolResult{
Success: data.Success,
Output: data.Output,
Error: data.Error,
Data: data.Data,
},
Duration: duration,
})
return nil
}
func isQuickAnswerTimelineTool(name string) bool {
_, ok := quickAnswerTimelineTools[name]
return ok
}
// ensureQuickAnswerStep returns the single step a fast-answer turn records
// into. There is no ReAct loop here, so everything belongs to iteration 0.
func ensureQuickAnswerStep(msg *types.Message) *types.AgentStep {
if len(msg.AgentSteps) == 0 {
msg.AgentSteps = types.AgentSteps{{
Iteration: 0,
Timestamp: time.Now(),
ToolCalls: make([]types.ToolCall, 0),
}}
}
return &msg.AgentSteps[0]
}
func appendQuickAnswerToolCall(msg *types.Message, call types.ToolCall) {
step := ensureQuickAnswerStep(msg)
for i := range step.ToolCalls {
if step.ToolCalls[i].ID == call.ID {
step.ToolCalls[i] = call
return
}
}
step.ToolCalls = append(step.ToolCalls, call)
}
@@ -0,0 +1,111 @@
package session
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func emitTimelineStage(
t *testing.T,
bus *event.EventBus,
toolCallID, toolName string,
args map[string]any,
result event.AgentToolResultData,
) {
t.Helper()
require.NoError(t, bus.Emit(context.Background(), event.Event{
Type: event.EventAgentToolCall,
Data: event.AgentToolCallData{ToolCallID: toolCallID, ToolName: toolName, Arguments: args},
}))
result.ToolCallID = toolCallID
result.ToolName = toolName
require.NoError(t, bus.Emit(context.Background(), event.Event{
Type: event.EventAgentToolResult,
Data: result,
}))
}
// A turn that searched and cited nothing is exactly the case history replay
// could not reconstruct from knowledge_references, so it must be persisted.
func TestQuickAnswerTimelineRecorderPersistsSearchWithoutResults(t *testing.T) {
bus := event.NewEventBus()
msg := &types.Message{}
registerQuickAnswerTimelineRecorder(bus, msg)
emitTimelineStage(t, bus, "call-1", "knowledge_search",
map[string]any{"query": "你好", "search_source": "knowledge"},
event.AgentToolResultData{
Output: "未检索到相关内容",
Success: true,
Duration: 12,
Data: map[string]interface{}{"count": 0, "doc_count": 0, "web_count": 0},
})
require.Len(t, msg.AgentSteps, 1)
require.Len(t, msg.AgentSteps[0].ToolCalls, 1)
call := msg.AgentSteps[0].ToolCalls[0]
assert.Equal(t, "knowledge_search", call.Name)
assert.Equal(t, types.PipelineToolCallIDPrefix+"call-1", call.ID)
assert.Equal(t, "你好", call.Args["query"])
require.NotNil(t, call.Result)
assert.True(t, call.Result.Success)
assert.Equal(t, "未检索到相关内容", call.Result.Output)
assert.Equal(t, 0, call.Result.Data["count"])
assert.Equal(t, int64(12), call.Duration)
}
func TestQuickAnswerTimelineRecorderKeepsStageOrderAndIgnoresOtherTools(t *testing.T) {
bus := event.NewEventBus()
msg := &types.Message{}
registerQuickAnswerTimelineRecorder(bus, msg)
emitTimelineStage(t, bus, "call-1", "query_understand", nil,
event.AgentToolResultData{Output: "已完成问题理解", Success: true})
emitTimelineStage(t, bus, "call-2", "web_search", nil,
event.AgentToolResultData{Output: "ignored", Success: true})
emitTimelineStage(t, bus, "call-3", "knowledge_search", nil,
event.AgentToolResultData{Output: "检索到 3 条相关内容", Success: true})
require.Len(t, msg.AgentSteps, 1)
names := make([]string, 0, len(msg.AgentSteps[0].ToolCalls))
for _, call := range msg.AgentSteps[0].ToolCalls {
names = append(names, call.Name)
}
assert.Equal(t, []string{"query_understand", "knowledge_search"}, names)
}
// A stage that never reported a result belongs to an interrupted turn. Keeping
// it out of history is what stops a reload from showing it as completed.
func TestQuickAnswerTimelineRecorderSkipsStagesWithoutResult(t *testing.T) {
bus := event.NewEventBus()
msg := &types.Message{}
registerQuickAnswerTimelineRecorder(bus, msg)
require.NoError(t, bus.Emit(context.Background(), event.Event{
Type: event.EventAgentToolCall,
Data: event.AgentToolCallData{ToolCallID: "call-1", ToolName: "knowledge_search"},
}))
assert.Empty(t, msg.AgentSteps)
}
// Reasoning and timeline stages share one step, so neither may clobber the other.
func TestQuickAnswerReasoningAndTimelineShareOneStep(t *testing.T) {
bus := event.NewEventBus()
msg := &types.Message{}
registerQuickAnswerTimelineRecorder(bus, msg)
appendQuickAnswerReasoning(msg, "思考中")
emitTimelineStage(t, bus, "call-1", "knowledge_search", nil,
event.AgentToolResultData{Output: "未检索到相关内容", Success: true})
appendQuickAnswerReasoning(msg, "继续")
require.Len(t, msg.AgentSteps, 1)
assert.Equal(t, "思考中继续", msg.AgentSteps[0].ReasoningContent)
require.Len(t, msg.AgentSteps[0].ToolCalls, 1)
}
@@ -0,0 +1,158 @@
package session
import (
"context"
"net/http"
"net/http/httptest"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/require"
)
type resolveAgentShareStub struct {
agent *types.CustomAgent
err error
}
func (s *resolveAgentShareStub) GetSharedAgentForTenant(
_ context.Context,
_ uint64,
_ types.TenantRole,
_ string,
_ ...uint64,
) (*types.CustomAgent, error) {
return s.agent, s.err
}
func (s *resolveAgentShareStub) ShareAgent(context.Context, string, string, string, uint64, types.OrgMemberRole) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) RemoveShare(context.Context, string, string, uint64) error {
panic("not implemented")
}
func (s *resolveAgentShareStub) ListSharesByAgent(context.Context, string, uint64) ([]*types.AgentShare, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) ListSharesByOrganization(context.Context, string) ([]*types.AgentShare, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) ListSharedAgents(context.Context, uint64, types.TenantRole) ([]*types.SharedAgentInfo, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) ListSharedAgentsInOrganization(context.Context, string, uint64, types.TenantRole) ([]*types.OrganizationSharedAgentItem, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) ListSharedAgentsInOrganizations(context.Context, []string, uint64, types.TenantRole) (map[string][]*types.OrganizationSharedAgentItem, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) SetSharedAgentDisabledByMe(context.Context, uint64, string, uint64, bool) error {
panic("not implemented")
}
func (s *resolveAgentShareStub) TenantCanAccessKBViaSomeSharedAgent(context.Context, uint64, types.TenantRole, *types.KnowledgeBase) (bool, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) GetShare(context.Context, string) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) GetShareByAgentAndOrg(context.Context, string, string) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) GetShareByAgentIDForTenant(context.Context, uint64, string, uint64) (*types.AgentShare, error) {
panic("not implemented")
}
func (s *resolveAgentShareStub) CountByOrganizations(context.Context, []string) (map[string]int64, error) {
panic("not implemented")
}
type resolveOwnAgentStub struct {
agent *types.CustomAgent
err error
}
func (s *resolveOwnAgentStub) GetAgentByID(context.Context, string) (*types.CustomAgent, error) {
return s.agent, s.err
}
func (s *resolveOwnAgentStub) CreateAgent(context.Context, *types.CustomAgent) (*types.CustomAgent, error) {
panic("not implemented")
}
func (s *resolveOwnAgentStub) GetAgentByIDAndTenant(context.Context, string, uint64) (*types.CustomAgent, error) {
panic("not implemented")
}
func (s *resolveOwnAgentStub) ListAgents(context.Context) ([]*types.CustomAgent, error) {
panic("not implemented")
}
func (s *resolveOwnAgentStub) UpdateAgent(context.Context, *types.CustomAgent) (*types.CustomAgent, error) {
panic("not implemented")
}
func (s *resolveOwnAgentStub) DeleteAgent(context.Context, string) error {
panic("not implemented")
}
func (s *resolveOwnAgentStub) CopyAgent(context.Context, string) (*types.CustomAgent, error) {
panic("not implemented")
}
func (s *resolveOwnAgentStub) GetSuggestedQuestions(context.Context, string, []string, []string, []types.TagScope, int) ([]types.SuggestedQuestion, error) {
panic("not implemented")
}
func (s *resolveOwnAgentStub) GetKnowledgeSuggestedQuestions(context.Context, string, []string, []string, []types.TagScope, int) ([]types.SuggestedQuestion, error) {
panic("not implemented")
}
func newResolveAgentTestContext(tenantID uint64) (*gin.Context, context.Context) {
gin.SetMode(gin.TestMode)
rec := httptest.NewRecorder()
c, _ := gin.CreateTestContext(rec)
c.Request = httptest.NewRequest(http.MethodPost, "/", nil)
c.Set(types.UserIDContextKey.String(), "user-1")
c.Set(types.TenantIDContextKey.String(), tenantID)
ctx := context.WithValue(c.Request.Context(), types.TenantIDContextKey, tenantID)
c.Request = c.Request.WithContext(ctx)
return c, ctx
}
func TestResolveAgent_RejectsInvalidSharedSelectorWithoutLocalFallback(t *testing.T) {
c, ctx := newResolveAgentTestContext(7)
localAgent := &types.CustomAgent{ID: "builtin-smart-reasoning", TenantID: 7, Name: "Local"}
h := &Handler{
agentShareService: &resolveAgentShareStub{},
customAgentService: &resolveOwnAgentStub{agent: localAgent},
}
agent, effectiveTenantID, sharedReadOnly := h.resolveAgent(ctx, c, "builtin-smart-reasoning", 84)
require.Nil(t, agent)
require.Equal(t, uint64(0), effectiveTenantID)
require.False(t, sharedReadOnly)
}
func TestResolveAgent_UsesSharedAgentWhenSourceSelectorMatches(t *testing.T) {
c, ctx := newResolveAgentTestContext(7)
sharedAgent := &types.CustomAgent{ID: "builtin-smart-reasoning", TenantID: 84, Name: "Shared"}
h := &Handler{
agentShareService: &resolveAgentShareStub{agent: sharedAgent},
customAgentService: &resolveOwnAgentStub{agent: &types.CustomAgent{ID: "builtin-smart-reasoning", TenantID: 7}},
}
agent, effectiveTenantID, sharedReadOnly := h.resolveAgent(ctx, c, "builtin-smart-reasoning", 84)
require.Equal(t, sharedAgent, agent)
require.Equal(t, uint64(84), effectiveTenantID)
require.True(t, sharedReadOnly)
}
func TestResolveAgent_FallsBackToLocalAgentWithoutSourceSelector(t *testing.T) {
c, ctx := newResolveAgentTestContext(7)
localAgent := &types.CustomAgent{ID: "builtin-smart-reasoning", TenantID: 7, Name: "Local"}
h := &Handler{
agentShareService: &resolveAgentShareStub{},
customAgentService: &resolveOwnAgentStub{agent: localAgent},
}
agent, effectiveTenantID, sharedReadOnly := h.resolveAgent(ctx, c, "builtin-smart-reasoning", 0)
require.Equal(t, localAgent, agent)
require.Equal(t, uint64(0), effectiveTenantID)
require.False(t, sharedReadOnly)
}
@@ -0,0 +1,40 @@
package session
import (
"context"
"fmt"
"github.com/Tencent/WeKnora/internal/types"
)
// Only call with the server-saved uploads or owned temporary documents prepared
// by prepareQARequest. Text and client-supplied URLs are not ownership evidence.
func (h *Handler) bindUploadedMessageResources(ctx context.Context, message *types.Message) error {
if h.resourceCatalog == nil || message == nil {
return nil
}
tenantID, _ := types.TenantIDFromContext(ctx)
refs := make(map[string]struct{})
for _, image := range message.Images {
refs[image.URL] = struct{}{}
}
for _, attachment := range message.Attachments {
refs[attachment.URL] = struct{}{}
}
for ref := range refs {
if _, ok := types.ParseResourcePath(ref); !ok {
continue
}
resource, err := h.resourceCatalog.Resolve(ctx, ref)
if err != nil {
return err
}
if resource == nil || resource.TenantID != tenantID || message.ID == "" {
return fmt.Errorf("invalid uploaded message resource")
}
if err := h.resourceCatalog.Bind(ctx, ref, types.ResourceOwnerMessage, message.ID, types.ResourceRelationAttachment); err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,45 @@
package session
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/require"
)
type uploadedResourceCatalog struct {
interfaces.ResourceCatalog
tenant uint64
bound []types.ResourceBinding
}
func (c *uploadedResourceCatalog) Resolve(context.Context, string) (*types.StoredResource, error) {
return &types.StoredResource{TenantID: c.tenant}, nil
}
func (c *uploadedResourceCatalog) Bind(_ context.Context, ref, ownerType, ownerID, relation string) error {
c.bound = append(c.bound, types.ResourceBinding{ResourceID: ref, OwnerType: ownerType, OwnerID: ownerID, Relation: relation})
return nil
}
func TestBindUploadedMessageResources(t *testing.T) {
ctx := context.WithValue(context.Background(), types.TenantIDContextKey, uint64(7))
ref := types.BuildResourcePath("AbCdEfGhIjKlMnOpQrStUv")
catalog := &uploadedResourceCatalog{tenant: 7}
h := &Handler{resourceCatalog: catalog}
message := &types.Message{
ID: "message-1", Content: types.BuildResourcePath("XbCdEfGhIjKlMnOpQrStUv"),
Images: types.MessageImages{{URL: ref}}, Attachments: types.MessageAttachments{{URL: ref}},
}
require.NoError(t, h.bindUploadedMessageResources(ctx, message))
require.Len(t, catalog.bound, 1)
require.Equal(t, types.ResourceOwnerMessage, catalog.bound[0].OwnerType)
require.Equal(t, "message-1", catalog.bound[0].OwnerID)
catalog.bound = nil
catalog.tenant = 8
require.Error(t, h.bindUploadedMessageResources(ctx, message))
require.Empty(t, catalog.bound)
}
+174
View File
@@ -0,0 +1,174 @@
package session
import (
"context"
stderrors "errors"
"strings"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/storageurl"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/gin-gonic/gin"
)
// resourceModeError turns a mode-resolution failure into the response the client
// should see: a rejected scope is a 403, a typo in the parameter is a 400.
func resourceModeError(err error) error {
if stderrors.Is(err, storageurl.ErrPublicModeForbidden) {
return errors.NewForbiddenError(err.Error())
}
return errors.NewBadRequestError(err.Error())
}
// resolveResourceRewriter builds the storage-reference rewriter for one response
// from the request's `resource_urls` parameter, falling back to the deployment
// default. The returned error is already an AppError the caller can hand to
// c.Error.
//
// In the default handle mode the returned rewriter is disabled, so responses are
// left exactly as before.
func (h *Handler) resolveResourceRewriter(c *gin.Context) (*storageurl.Rewriter, error) {
ctx := c.Request.Context()
mode, err := storageurl.ResolveMode(ctx, c.Query(storageurl.QueryParam))
if err != nil {
return nil, resourceModeError(err)
}
return storageurl.NewRequestRewriter(ctx, mode, h.fileService, h.storageResolver), nil
}
// resolveStreamRewriter is resolveResourceRewriter plus the holdback buffer an
// SSE response needs, because a storage reference can straddle two deltas. It
// must be called before any SSE header is written so an invalid value is still
// reportable as a normal JSON error.
func (h *Handler) resolveStreamRewriter(c *gin.Context) (*storageurl.StreamRewriter, error) {
rewriter, err := h.resolveResourceRewriter(c)
if err != nil {
return nil, err
}
return storageurl.NewStreamRewriter(rewriter), nil
}
// deltaResponseTypes are the SSE events whose Content is an incremental chunk
// that clients accumulate. A storage reference can straddle two chunks, so these
// go through the holdback buffer; every other event carries a complete value.
var deltaResponseTypes = map[types.ResponseType]bool{
types.ResponseTypeAnswer: true,
types.ResponseTypeThinking: true,
types.ResponseTypeReflection: true,
}
// terminalResponseTypes end the message as far as the client is concerned, so
// any buffered tail must be released just before them. An error can be the last
// event a run produces, and a completion may or may not follow it; flushing an
// already-empty buffer is a no-op, so covering both is safe.
var terminalResponseTypes = map[types.ResponseType]bool{
types.ResponseTypeComplete: true,
types.ResponseTypeError: true,
}
// holdbackKey identifies one delta stream. The event id is the key clients
// accumulate on, so interleaved answer and thinking streams hold back
// independently; the type prefix lets a flushed remainder be re-emitted as the
// event type it came from.
func holdbackKey(responseType types.ResponseType, eventID string) string {
return string(responseType) + "\x00" + eventID
}
func parseHoldbackKey(key string) (types.ResponseType, string) {
responseType, eventID, _ := strings.Cut(key, "\x00")
return types.ResponseType(responseType), eventID
}
// buildStreamResponseFor builds the SSE payload for evt and, in public mode,
// replaces storage references with URLs the client can load directly.
func buildStreamResponseFor(
ctx context.Context,
evt interfaces.StreamEvent,
requestID string,
rewriter *storageurl.StreamRewriter,
) *types.StreamResponse {
response := buildStreamResponse(evt, requestID)
if !rewriter.Enabled() {
return response
}
response.KnowledgeReferences = rewriter.Rewriter().CopyReferences(ctx, response.KnowledgeReferences)
response.Data = rewriter.Rewriter().CopyData(ctx, response.Data)
if deltaResponseTypes[evt.Type] {
// The rewritten Data rides along with the held tail so a late release
// carries the same metadata as the event it was cut from.
response.Content = rewriter.Push(
ctx, holdbackKey(evt.Type, evt.ID), response.Content, evt.Done, response.Data)
} else {
response.Content = rewriter.Rewriter().String(ctx, response.Content)
}
return response
}
// emitStreamEvent writes one SSE payload. Content still sitting in the holdback
// buffer is released first when evt terminates the stream, because clients treat
// the completion marker as the end of the message.
func emitStreamEvent(
ctx context.Context,
c *gin.Context,
evt interfaces.StreamEvent,
requestID string,
rewriter *storageurl.StreamRewriter,
) {
response := buildStreamResponseFor(ctx, evt, requestID, rewriter)
if terminalResponseTypes[evt.Type] {
flushHeldStreamContent(ctx, c, requestID, rewriter)
}
c.SSEvent("message", response)
c.Writer.Flush()
}
// flushHeldStreamContent emits whatever the holdback buffer still retains, so a
// trailing reference is not dropped when a delta stream ends without a terminal
// chunk. Every path that stops streaming while the client is still connected
// must call it — completion, a user-requested stop, or giving up on the event
// store — otherwise the tail is silently lost. If the client has already gone
// there is nobody left to receive it.
func flushHeldStreamContent(
ctx context.Context,
c *gin.Context,
requestID string,
rewriter *storageurl.StreamRewriter,
) {
held := rewriter.FlushAll(ctx)
if len(held) == 0 || c.Request.Context().Err() != nil {
return
}
for key, fragment := range held {
if fragment.Content == "" {
continue
}
responseType, eventID := parseHoldbackKey(key)
logger.Debugf(ctx, "Flushing held stream fragment, type: %s, event: %s", responseType, eventID)
c.SSEvent("message", &types.StreamResponse{
ID: requestID,
ResponseType: responseType,
Content: fragment.Content,
Data: heldFragmentData(fragment.Meta, eventID),
})
c.Writer.Flush()
}
}
// heldFragmentData rebuilds the metadata for a released tail from the event it
// was cut from, so a client keying off `event_id` — or off anything else the
// original event carried, such as `is_fallback` — sees the same shape. The map
// is copied because an unchanged rewrite returns the stream buffer's own map.
func heldFragmentData(meta interface{}, eventID string) map[string]interface{} {
original, _ := meta.(map[string]interface{})
data := make(map[string]interface{}, len(original)+1)
for key, value := range original {
data[key] = value
}
if _, ok := data["event_id"]; !ok {
data["event_id"] = eventID
}
return data
}
@@ -0,0 +1,256 @@
package session
import (
"context"
"io"
"mime/multipart"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/event"
"github.com/Tencent/WeKnora/internal/storageurl"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
const testResourceHandle = "resource://xifDo7NTSL300Lp1goVutw"
// stubResourceFileService resolves any storage reference to one fixed public URL.
type stubResourceFileService struct {
interfaces.FileService
}
func (s *stubResourceFileService) GetFileURL(context.Context, string) (string, error) {
return "https://cdn.example.com/signed.png", nil
}
func (s *stubResourceFileService) SaveFile(
context.Context, *multipart.FileHeader, uint64, string,
) (string, error) {
return "", nil
}
func (s *stubResourceFileService) GetFile(context.Context, string) (io.ReadCloser, error) {
return nil, nil
}
func publicStreamRewriter() *storageurl.StreamRewriter {
return storageurl.NewStreamRewriter(storageurl.NewRequestRewriter(
context.Background(), storageurl.ModePublic, &stubResourceFileService{}, nil))
}
func newTestGinContext(t *testing.T, query string) (*gin.Context, *httptest.ResponseRecorder) {
t.Helper()
gin.SetMode(gin.TestMode)
recorder := httptest.NewRecorder()
c, _ := gin.CreateTestContext(recorder)
c.Request = httptest.NewRequest(http.MethodPost, "/knowledge-chat/sess1"+query, nil)
return c, recorder
}
// The default mode must leave the stream byte-identical and unbuffered.
func TestResolveStreamRewriter_DefaultIsDisabled(t *testing.T) {
h := &Handler{fileService: &stubResourceFileService{}}
c, _ := newTestGinContext(t, "")
rewriter, err := h.resolveStreamRewriter(c)
require.NoError(t, err)
assert.False(t, rewriter.Enabled())
}
func TestResolveStreamRewriter_RejectsInvalidValue(t *testing.T) {
h := &Handler{fileService: &stubResourceFileService{}}
c, _ := newTestGinContext(t, "?resource_urls=signed")
_, err := h.resolveStreamRewriter(c)
require.Error(t, err)
assert.Contains(t, err.Error(), "resource_urls")
}
// The default mode must not alter a payload at all.
func TestBuildStreamResponseFor_DisabledPassesThrough(t *testing.T) {
rewriter := storageurl.NewStreamRewriter(storageurl.NewRewriter(nil, "TEST"))
evt := interfaces.StreamEvent{
ID: "answer-1",
Type: types.ResponseTypeAnswer,
Content: "see ![fig](" + testResourceHandle + ")",
}
response := buildStreamResponseFor(context.Background(), evt, "req-1", rewriter)
assert.Equal(t, evt.Content, response.Content)
}
// Answer deltas are chunks the client accumulates, so a handle split across two
// events must be held back and rewritten once complete rather than emitted broken.
func TestBuildStreamResponseFor_HoldsReferenceSplitAcrossDeltas(t *testing.T) {
rewriter := publicStreamRewriter()
ctx := context.Background()
first := buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "answer-1",
Type: types.ResponseTypeAnswer,
Content: "see ![fig](resource://xifDo7",
}, "req-1", rewriter)
assert.Equal(t, "see ", first.Content, "the incomplete reference must be held back")
second := buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "answer-1",
Type: types.ResponseTypeAnswer,
Content: "NTSL300Lp1goVutw) done",
}, "req-1", rewriter)
assert.Equal(t, "![fig](https://cdn.example.com/signed.png) done", second.Content)
}
// Interleaved answer and thinking streams must not corrupt each other's buffers.
func TestBuildStreamResponseFor_DeltaStreamsAreIndependent(t *testing.T) {
rewriter := publicStreamRewriter()
ctx := context.Background()
answer := buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "![a](resource://xifDo7NTSL",
}, "req-1", rewriter)
assert.Empty(t, answer.Content)
thinking := buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "think-1", Type: types.ResponseTypeThinking, Content: "reasoning text",
}, "req-1", rewriter)
assert.Equal(t, "reasoning text", thinking.Content)
answer = buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "300Lp1goVutw)",
}, "req-1", rewriter)
assert.Equal(t, "![a](https://cdn.example.com/signed.png)", answer.Content)
}
// Non-delta events carry a complete value, so they must be rewritten immediately
// rather than waiting for a chunk that will never arrive.
func TestBuildStreamResponseFor_NonDeltaContentIsRewrittenImmediately(t *testing.T) {
response := buildStreamResponseFor(context.Background(), interfaces.StreamEvent{
ID: "tool-1",
Type: types.ResponseTypeToolResult,
Content: "chart ![c](" + testResourceHandle + ")",
}, "req-1", publicStreamRewriter())
assert.Equal(t, "chart ![c](https://cdn.example.com/signed.png)", response.Content)
}
// The references payload and tool metadata share pointers and maps with the
// stream replay buffer, so rewriting must not mutate the source event.
func TestBuildStreamResponseFor_DoesNotMutateSourceEvent(t *testing.T) {
reference := &types.SearchResult{Content: "chunk ![c](" + testResourceHandle + ")"}
evt := interfaces.StreamEvent{
ID: "refs-1",
Type: types.ResponseTypeReferences,
Data: map[string]interface{}{
"references": types.References{reference},
"output": "chart ![o](" + testResourceHandle + ")",
},
}
response := buildStreamResponseFor(context.Background(), evt, "req-1", publicStreamRewriter())
require.Len(t, response.KnowledgeReferences, 1)
assert.Equal(t, "chunk ![c](https://cdn.example.com/signed.png)",
response.KnowledgeReferences[0].Content)
assert.Equal(t, "chunk ![c]("+testResourceHandle+")", reference.Content,
"the replay buffer's SearchResult must be untouched")
assert.Equal(t, "chart ![o]("+testResourceHandle+")", evt.Data["output"],
"the replay buffer's metadata map must be untouched")
assert.Equal(t, "chart ![o](https://cdn.example.com/signed.png)", response.Data["output"])
}
// A trailing reference held back when the stream ends must still be delivered,
// as the event type it came from, before the completion marker.
func TestEmitStreamEvent_FlushesHeldContentBeforeCompletion(t *testing.T) {
rewriter := publicStreamRewriter()
ctx := context.Background()
c, recorder := newTestGinContext(t, "?resource_urls=public")
held := buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "tail ![fig](resource://xifDo7",
}, "req-1", rewriter)
require.Equal(t, "tail ", held.Content)
emitStreamEvent(ctx, c, interfaces.StreamEvent{
ID: "complete-1", Type: types.ResponseTypeComplete, Done: true,
}, "req-1", rewriter)
body := recorder.Body.String()
assert.Contains(t, body, `"response_type":"answer"`)
assert.Contains(t, body, `![fig](resource://xifDo7`,
"an incomplete reference cannot be resolved, but must not be swallowed")
assert.Less(t,
indexOf(body, `"response_type":"answer"`),
indexOf(body, `"response_type":"complete"`),
"held content must precede the completion marker",
)
}
// An error can be the last event of a run, so it must release the buffer too —
// otherwise the tail generated before the failure is lost.
func TestEmitStreamEvent_FlushesHeldContentOnError(t *testing.T) {
rewriter := publicStreamRewriter()
ctx := context.Background()
c, recorder := newTestGinContext(t, "?resource_urls=public")
buildStreamResponseFor(ctx, interfaces.StreamEvent{
ID: "answer-1",
Type: types.ResponseTypeAnswer,
Content: "tail ![fig](resource://xifDo7",
Data: map[string]interface{}{"event_id": "answer-1", "is_fallback": true},
}, "req-1", rewriter)
emitStreamEvent(ctx, c, interfaces.StreamEvent{
ID: "err-1", Type: types.ResponseTypeError, Content: "upstream failed", Done: true,
}, "req-1", rewriter)
body := recorder.Body.String()
assert.Contains(t, body, `![fig](resource://xifDo7`, "the tail must not be swallowed")
assert.Contains(t, body, `"is_fallback":true`,
"a released tail must carry the metadata of the event it was cut from")
assert.Less(t,
indexOf(body, `"response_type":"answer"`),
indexOf(body, `"response_type":"error"`),
"held content must precede the error",
)
}
// A user-requested stop ends the stream without a completion event, and the text
// generated before it is still the user's content.
func TestHandleAgentEventsForSSE_FlushesHeldContentOnStop(t *testing.T) {
h := &Handler{streamManager: &stubStreamManager{events: []interfaces.StreamEvent{
{ID: "answer-1", Type: types.ResponseTypeAnswer, Content: "tail ![fig](resource://xifDo7"},
{ID: "stop-1", Type: types.ResponseType(event.EventStop), Done: true},
}}}
c, recorder := newTestGinContext(t, "?resource_urls=public")
h.handleAgentEventsForSSE(
context.Background(), c, "sess1", "msg1", "req-1", nil, false, publicStreamRewriter())
body := recorder.Body.String()
assert.Contains(t, body, `![fig](resource://xifDo7`, "the tail must not be swallowed")
assert.Less(t,
indexOf(body, `"response_type":"answer"`),
indexOf(body, `"response_type":"stop"`),
"held content must precede the stop notification",
)
}
func TestHoldbackKeyRoundTrip(t *testing.T) {
responseType, eventID := parseHoldbackKey(holdbackKey(types.ResponseTypeThinking, "think-1"))
assert.Equal(t, types.ResponseTypeThinking, responseType)
assert.Equal(t, "think-1", eventID)
}
func indexOf(haystack, needle string) int {
for i := 0; i+len(needle) <= len(haystack); i++ {
if haystack[i:i+len(needle)] == needle {
return i
}
}
return -1
}
@@ -0,0 +1,100 @@
package session
import (
"bytes"
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"github.com/gin-gonic/gin"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/Tencent/WeKnora/internal/middleware"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
)
type stubSearchSessionService struct {
interfaces.SessionService
}
func (s *stubSearchSessionService) SearchKnowledge(
_ context.Context, _ []string, _ []string, _ []types.TagScope, _ string,
) ([]*types.SearchResult, error) {
return []*types.SearchResult{{
Content: "chunk ![c](" + testResourceHandle + ")",
ImageInfo: `[{"url":"` + testResourceHandle + `"}]`,
}}, nil
}
func TestSearchKnowledge_PublicResourceURLs(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &Handler{
sessionService: &stubSearchSessionService{},
fileService: &stubResourceFileService{},
}
r.POST("/knowledge-search", h.SearchKnowledge)
body := bytes.NewBufferString(`{"query":"diagram","knowledge_base_ids":["kb-1"]}`)
req := httptest.NewRequest(http.MethodPost, "/knowledge-search?resource_urls=public", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
assert.NotContains(t, w.Body.String(), testResourceHandle)
assert.Contains(t, w.Body.String(), "cdn.example.com")
}
func TestSearchKnowledge_InvalidResourceURLMode(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &Handler{
sessionService: &stubSearchSessionService{},
fileService: &stubResourceFileService{},
}
r.POST("/knowledge-search", h.SearchKnowledge)
body := bytes.NewBufferString(`{"query":"diagram","knowledge_base_ids":["kb-1"]}`)
req := httptest.NewRequest(http.MethodPost, "/knowledge-search?resource_urls=signed", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusBadRequest, w.Code, "body=%s", w.Body.String())
assert.Contains(t, w.Body.String(), "resource_urls")
}
func TestSearchKnowledge_DefaultKeepsHandles(t *testing.T) {
gin.SetMode(gin.TestMode)
r := gin.New()
r.Use(middleware.ErrorHandler())
h := &Handler{
sessionService: &stubSearchSessionService{},
fileService: &stubResourceFileService{},
}
r.POST("/knowledge-search", h.SearchKnowledge)
body := bytes.NewBufferString(`{"query":"diagram","knowledge_base_ids":["kb-1"]}`)
req := httptest.NewRequest(http.MethodPost, "/knowledge-search", body)
req.Header.Set("Content-Type", "application/json")
w := httptest.NewRecorder()
r.ServeHTTP(w, req)
require.Equal(t, http.StatusOK, w.Code, "body=%s", w.Body.String())
var resp struct {
Data []struct {
Content string `json:"content"`
} `json:"data"`
}
require.NoError(t, json.Unmarshal(w.Body.Bytes(), &resp))
require.Len(t, resp.Data, 1)
assert.Contains(t, resp.Data[0].Content, testResourceHandle)
}
+474
View File
@@ -0,0 +1,474 @@
package session
import (
"context"
stderrors "errors"
"fmt"
"net/http"
"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/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
"gorm.io/gorm"
)
// ContinueStream godoc
// @Summary 继续流式响应
// @Description 继续获取正在进行的流式响应
// @Tags 问答
// @Accept json
// @Produce text/event-stream
// @Param session_id path string true "会话ID"
// @Param message_id query string true "消息ID"
// @Param resource_urls query string false "文件引用形式,public 返回可加载直链" Enums(handle, public) default(handle)
// @Success 200 {object} map[string]interface{} "流式响应"
// @Failure 404 {object} errors.AppError "会话或消息不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/continue-stream/{session_id} [get]
func (h *Handler) ContinueStream(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start continuing stream response processing")
// Get session ID from URL parameter
sessionID := secutils.SanitizeForLog(c.Param("session_id"))
if sessionID == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Get message ID from query parameter
messageID := secutils.SanitizeForLog(c.Query("message_id"))
if messageID == "" {
logger.Error(ctx, "Message ID is empty")
c.Error(errors.NewBadRequestError("Missing message ID"))
return
}
logger.Infof(ctx, "Continuing stream, session ID: %s, message ID: %s", sessionID, messageID)
// Resolve before any SSE header is written so an invalid resource_urls value
// is still reportable as a normal 400 JSON error.
resourceRewriter, err := h.resolveStreamRewriter(c)
if err != nil {
logger.Warnf(ctx, "Rejected resource URL mode: %v", err)
_ = c.Error(err)
return
}
// Verify that the session exists and belongs to this tenant
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
c.Error(errors.NewNotFoundError(err.Error()))
} else {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
}
return
}
// Get the incomplete message
message, err := h.messageService.GetMessage(ctx, sessionID, messageID)
if err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
// PR #1309 plumbed user-scope into messageService.GetMessage's
// session existence check; non-owner / wrong-user lookups now
// surface as ErrSessionNotFound. Map to 404 so clients can tell
// "wrong URL" from a real 5xx instead of seeing a generic 500.
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
if stderrors.Is(err, gorm.ErrRecordNotFound) {
// The message_id doesn't exist (e.g. a wrong / non-persisted id, or an
// expired replay buffer). That is a client error, not a server fault:
// return 404 so callers read resource.not_found (a permanent condition
// they must not retry) instead of a retryable 5xx. Mirrors the
// ErrSessionNotFound branch above and the kb/doc/chunk not-found fix.
logger.Warnf(ctx, "Message not found, session ID: %s, message ID: %s", sessionID, messageID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
if message == nil {
logger.Warnf(ctx, "Incomplete message not found, session ID: %s, message ID: %s", sessionID, messageID)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "Incomplete message not found",
})
return
}
// Get initial events from stream (offset 0)
events, currentOffset, err := h.streamManager.GetEvents(ctx, sessionID, messageID, 0)
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(fmt.Sprintf("Failed to get stream data: %s", err.Error())))
return
}
if len(events) == 0 {
logger.Warnf(ctx, "No events found in stream, session ID: %s, message ID: %s", sessionID, messageID)
c.JSON(http.StatusNotFound, gin.H{
"success": false,
"error": "No stream events found",
})
return
}
logger.Infof(
ctx, "Preparing to replay %d events and continue streaming, session ID: %s, message ID: %s",
len(events), sessionID, messageID,
)
// Set headers for SSE
setSSEHeaders(c)
// Check if stream is already completed
streamCompleted := false
for _, evt := range events {
if evt.Type == "complete" {
streamCompleted = true
break
}
}
// Replay existing events
logger.Debugf(ctx, "Replaying %d existing events", len(events))
for _, evt := range events {
emitStreamEvent(ctx, c, evt, message.RequestID, resourceRewriter)
}
// If stream is already completed, send final event and return
if streamCompleted {
logger.Infof(ctx, "Stream already completed, session ID: %s, message ID: %s", sessionID, messageID)
sendCompletionEvent(c, message.RequestID)
return
}
// Continue polling for new events
logger.Debug(ctx, "Starting event update monitoring")
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
for {
select {
case <-c.Request.Context().Done():
logger.Debug(ctx, "Client connection closed")
return
case <-ticker.C:
// Get new events from current offset
newEvents, newOffset, err := h.streamManager.GetEvents(ctx, sessionID, messageID, currentOffset)
if err != nil {
logger.Errorf(ctx, "Failed to get new events: %v", err)
flushHeldStreamContent(ctx, c, message.RequestID, resourceRewriter)
return
}
// Send new events
streamCompletedNow := false
for _, evt := range newEvents {
// Check for completion event
if evt.Type == "complete" {
streamCompletedNow = true
}
emitStreamEvent(ctx, c, evt, message.RequestID, resourceRewriter)
}
// Update offset
currentOffset = newOffset
// If stream completed, send final event and exit
if streamCompletedNow {
logger.Infof(ctx, "Stream completed, session ID: %s, message ID: %s", sessionID, messageID)
sendCompletionEvent(c, message.RequestID)
return
}
}
}
}
// StopSession godoc
// @Summary 停止生成
// @Description 停止当前正在进行的生成任务
// @Tags 问答
// @Accept json
// @Produce json
// @Param session_id path string true "会话ID"
// @Param request body StopSessionRequest true "停止请求"
// @Success 200 {object} map[string]interface{} "停止成功"
// @Failure 404 {object} errors.AppError "会话或消息不存在"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{session_id}/stop [post]
func (h *Handler) StopSession(c *gin.Context) {
ctx := logger.CloneContext(c.Request.Context())
sessionID := secutils.SanitizeForLog(c.Param("session_id"))
if sessionID == "" {
c.JSON(400, gin.H{"error": "Session ID is required"})
return
}
// Parse request body to get message_id
var req StopSessionRequest
if err := c.ShouldBindJSON(&req); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": sessionID,
})
c.JSON(400, gin.H{"error": "message_id is required"})
return
}
assistantMessageID := secutils.SanitizeForLog(req.MessageID)
logger.Infof(ctx, "Stop generation request for session: %s, message: %s", sessionID, assistantMessageID)
// Get tenant ID from context
tenantID, exists := c.Get(types.TenantIDContextKey.String())
if !exists {
logger.Error(ctx, "Failed to get tenant ID")
c.JSON(401, gin.H{"error": "Unauthorized"})
return
}
tenantIDUint := tenantID.(uint64)
// Verify message ownership and status
message, err := h.messageService.GetMessage(ctx, sessionID, assistantMessageID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": sessionID,
"message_id": assistantMessageID,
})
c.JSON(404, gin.H{"error": "Message not found"})
return
}
// Verify message belongs to this session (double check)
if message.SessionID != sessionID {
logger.Warnf(ctx, "Message %s does not belong to session %s", assistantMessageID, sessionID)
c.JSON(403, gin.H{"error": "Message does not belong to this session"})
return
}
// Verify message belongs to the current tenant. Stopping generation mutates
// an in-flight run, so use the strict owner scope: a tenant admin may read an
// API-key session but must not be able to interrupt its (external) API calls.
session, err := h.sessionService.GetOwnedSession(ctx, sessionID)
if err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": sessionID,
})
c.JSON(404, gin.H{"error": "Session not found"})
return
}
if session.TenantID != tenantIDUint {
logger.Warnf(ctx, "Session %s does not belong to tenant %d", sessionID, tenantIDUint)
c.JSON(403, gin.H{"error": "Access denied"})
return
}
// Check if message is already completed (stopped)
if message.IsCompleted {
logger.Infof(ctx, "Message %s is already completed, no need to stop", assistantMessageID)
c.JSON(200, gin.H{
"success": true,
"message": "Message already completed",
})
return
}
// Write stop event to StreamManager for distributed support
stopEvent := interfaces.StreamEvent{
ID: fmt.Sprintf("stop-%d", time.Now().UnixNano()),
Type: types.ResponseType(event.EventStop),
Content: "",
Done: true,
Timestamp: time.Now(),
Data: map[string]interface{}{
"session_id": sessionID,
"message_id": assistantMessageID,
"reason": "user_requested",
},
}
if err := h.streamManager.AppendEvent(ctx, sessionID, assistantMessageID, stopEvent); err != nil {
logger.ErrorWithFields(ctx, err, map[string]interface{}{
"session_id": sessionID,
"message_id": assistantMessageID,
})
c.JSON(500, gin.H{"error": "Failed to write stop event"})
return
}
logger.Infof(ctx, "Stop event written successfully for session: %s, message: %s", sessionID, assistantMessageID)
c.JSON(200, gin.H{
"success": true,
"message": "Generation stopped",
})
}
// handleAgentEventsForSSE handles agent events for SSE streaming using an existing handler
// The handler is already subscribed to events and AgentQA is already running
// This function polls StreamManager and pushes events to SSE, allowing graceful handling of disconnections
// waitForTitle: if true, wait for title event after completion (for new sessions without title)
func (h *Handler) handleAgentEventsForSSE(
ctx context.Context,
c *gin.Context,
sessionID, assistantMessageID, requestID string,
eventBus *event.EventBus,
waitForTitle bool,
resourceRewriter *storageurl.StreamRewriter,
) {
ticker := time.NewTicker(100 * time.Millisecond)
defer ticker.Stop()
lastOffset := 0
log := logger.GetLogger(ctx)
log.Infof("Starting pull-based SSE streaming for session=%s, message=%s", sessionID, assistantMessageID)
for {
select {
case <-c.Request.Context().Done():
// Connection closed, exit gracefully without panic
log.Infof(
"Client disconnected, stopping SSE streaming for session=%s, message=%s",
sessionID,
assistantMessageID,
)
return
case <-ticker.C:
// Get new events from StreamManager using offset
events, newOffset, err := h.streamManager.GetEvents(ctx, sessionID, assistantMessageID, lastOffset)
if err != nil {
log.Warnf("Failed to get events from stream: %v", err)
continue
}
// Send any new events
streamCompleted := false
titleReceived := false
for _, evt := range events {
// Check for stop event
if evt.Type == types.ResponseType(event.EventStop) {
log.Infof("Detected stop event, triggering stop via EventBus for session=%s", sessionID)
// Emit stop event to the EventBus to trigger context cancellation
if eventBus != nil {
eventBus.Emit(ctx, event.Event{
Type: event.EventStop,
SessionID: sessionID,
Data: event.StopData{
SessionID: sessionID,
MessageID: assistantMessageID,
Reason: "user_requested",
},
})
}
// Release any buffered tail first: the answer generated
// before the stop is still the user's content, and in
// public resource URL mode part of it may be sitting in
// the holdback buffer.
flushHeldStreamContent(ctx, c, requestID, resourceRewriter)
// Send stop notification to frontend
c.SSEvent("message", &types.StreamResponse{
ID: requestID,
ResponseType: "stop",
Content: "Generation stopped by user",
Done: true,
})
c.Writer.Flush()
return
}
// Check for completion event
if evt.Type == "complete" {
streamCompleted = true
}
// Check for title event
if evt.Type == types.ResponseTypeSessionTitle {
titleReceived = true
}
// Check if connection is still alive before writing. Build the
// payload only after this check: in public resource URL mode
// building consumes the chunk into the holdback buffer, so an
// early return here would drop it.
if c.Request.Context().Err() != nil {
log.Info("Connection closed during event sending, stopping")
return
}
emitStreamEvent(ctx, c, evt, requestID, resourceRewriter)
}
// Update offset
lastOffset = newOffset
// Check if stream is completed - wait for title event only if needed and not already received
if streamCompleted {
if waitForTitle && !titleReceived {
log.Infof("Stream completed for session=%s, message=%s, waiting for title event", sessionID, assistantMessageID)
// Wait up to 3 seconds for title event after completion
titleTimeout := time.After(3 * time.Second)
titleWaitLoop:
for {
select {
case <-titleTimeout:
log.Info("Title wait timeout, closing stream")
break titleWaitLoop
case <-c.Request.Context().Done():
log.Info("Connection closed while waiting for title")
return
default:
// Check for new events (title event)
events, newOff, err := h.streamManager.GetEvents(c.Request.Context(), sessionID, assistantMessageID, lastOffset)
if err != nil {
log.Warnf("Error getting events while waiting for title: %v", err)
break titleWaitLoop
}
if len(events) > 0 {
for _, evt := range events {
emitStreamEvent(ctx, c, evt, requestID, resourceRewriter)
// If we got the title, we can exit
if evt.Type == types.ResponseTypeSessionTitle {
log.Infof("Title event received: %s", evt.Content)
break titleWaitLoop
}
}
lastOffset = newOff
} else {
// No events, wait a bit before checking again
time.Sleep(100 * time.Millisecond)
}
}
}
} else {
log.Infof("Stream completed for session=%s, message=%s", sessionID, assistantMessageID)
}
sendCompletionEvent(c, requestID)
return
}
}
}
}
@@ -0,0 +1,50 @@
package session
import (
"encoding/json"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
func TestBuildStreamResponsePromotesUsageOnCompleteEvents(t *testing.T) {
usage := &types.TokenUsage{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}
response := buildStreamResponse(interfaces.StreamEvent{
Type: types.ResponseTypeComplete,
Done: true,
Data: map[string]interface{}{"total_steps": 3, "usage": usage},
Usage: usage,
}, "req-1")
require.NotNil(t, response.Usage)
assert.Equal(t, 120, response.Usage.TotalTokens)
assert.Equal(t, usage, response.Data["usage"])
}
func TestBuildStreamResponseLeavesUsageNilWhenAbsent(t *testing.T) {
response := buildStreamResponse(interfaces.StreamEvent{
Type: types.ResponseTypeAnswer,
Data: map[string]interface{}{"event_id": "e-1"},
}, "req-1")
assert.Nil(t, response.Usage)
}
func TestStreamEventUsageSurvivesJSONRoundTrip(t *testing.T) {
// The stream manager persists events as JSON (Redis) before the SSE loop
// reads them back — the typed usage must survive that round trip.
usage := &types.TokenUsage{PromptTokens: 100, CompletionTokens: 20, TotalTokens: 120}
usage.SetPromptCacheUsage(80, 0, 20, true)
raw, err := json.Marshal(interfaces.StreamEvent{Type: types.ResponseTypeComplete, Usage: usage})
require.NoError(t, err)
var restored interfaces.StreamEvent
require.NoError(t, json.Unmarshal(raw, &restored))
require.NotNil(t, restored.Usage)
assert.Equal(t, *usage, *restored.Usage)
}
@@ -0,0 +1,128 @@
package session
import (
"context"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/Tencent/WeKnora/internal/types/interfaces"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)
// attachmentMsgStub implements just the two MessageService methods used by
// persistResolvedAttachmentContent; the rest are inherited from the embedded
// interface and panic if unexpectedly called.
type attachmentMsgStub struct {
interfaces.MessageService
stored *types.Message
updated *types.Message
updateCalls int
}
func (s *attachmentMsgStub) GetMessage(_ context.Context, _ string, _ string) (*types.Message, error) {
return s.stored, nil
}
func (s *attachmentMsgStub) UpdateMessage(_ context.Context, message *types.Message) error {
s.updateCalls++
s.updated = message
return nil
}
func newAttachmentReqCtx() *qaRequestContext {
return &qaRequestContext{
sessionID: "sess-1",
userMessageID: "user-msg-1",
session: &types.Session{TenantID: 42},
}
}
// TestPersistResolvedAttachmentContent_EnrichesMatchingAttachments is the core
// regression guard for PR #2086: the user message is created with
// metadata-only attachment entries, and the parsed content selected after the
// SSE stream starts must be written back so multi-turn (Agent-mode) history can
// replay it via the Attachments column.
func TestPersistResolvedAttachmentContent_EnrichesMatchingAttachments(t *testing.T) {
stub := &attachmentMsgStub{
stored: &types.Message{
ID: "user-msg-1",
SessionID: "sess-1",
Role: "user",
Attachments: types.MessageAttachments{
{ID: "doc-1", FileName: "report.pdf", FileType: ".pdf"},
// Legacy inline upload without an ID must stay untouched.
{FileName: "note.txt", FileType: ".txt", Content: "legacy inline"},
},
},
}
h := &Handler{messageService: stub}
resolved := types.MessageAttachments{
{
ID: "doc-1", FileName: "report.pdf", FileType: ".pdf",
Content: "parsed body text", ContentMode: "selected_chunks",
SelectedChunks: 1, TotalChunks: 3, TokenCount: 12,
},
}
h.persistResolvedAttachmentContent(context.Background(), newAttachmentReqCtx(), resolved)
require.Equal(t, 1, stub.updateCalls, "matching attachment must trigger one persist")
require.NotNil(t, stub.updated)
require.Len(t, stub.updated.Attachments, 2)
assert.Equal(t, "parsed body text", stub.updated.Attachments[0].Content)
assert.Equal(t, "selected_chunks", stub.updated.Attachments[0].ContentMode)
assert.Equal(t, 3, stub.updated.Attachments[0].TotalChunks)
// Legacy inline attachment (no ID) is preserved verbatim.
assert.Equal(t, "legacy inline", stub.updated.Attachments[1].Content)
}
func TestPersistResolvedAttachmentContent_NoMatchSkipsUpdate(t *testing.T) {
stub := &attachmentMsgStub{
stored: &types.Message{
ID: "user-msg-1", SessionID: "sess-1", Role: "user",
Attachments: types.MessageAttachments{{ID: "doc-1", FileName: "a.pdf"}},
},
}
h := &Handler{messageService: stub}
resolved := types.MessageAttachments{{ID: "doc-other", Content: "x"}}
h.persistResolvedAttachmentContent(context.Background(), newAttachmentReqCtx(), resolved)
assert.Equal(t, 0, stub.updateCalls, "no matching ID must not persist")
}
func TestPersistResolvedAttachmentContent_GuardsEmptyInput(t *testing.T) {
stub := &attachmentMsgStub{}
h := &Handler{messageService: stub}
// No resolved attachments.
h.persistResolvedAttachmentContent(context.Background(), newAttachmentReqCtx(), nil)
// No user message ID.
rc := newAttachmentReqCtx()
rc.userMessageID = ""
h.persistResolvedAttachmentContent(context.Background(), rc, types.MessageAttachments{{ID: "doc-1", Content: "x"}})
assert.Equal(t, 0, stub.updateCalls)
}
func TestNormalizeTemporaryAttachmentIDs(t *testing.T) {
t.Run("rejects over limit before lookup", func(t *testing.T) {
ids := []string{"a", "b", "c", "d", "e", "f"}
_, err := normalizeTemporaryAttachmentIDs(ids)
require.Error(t, err)
assert.Contains(t, err.Error(), "at most 5")
})
t.Run("dedupes and drops empty", func(t *testing.T) {
got, err := normalizeTemporaryAttachmentIDs([]string{" a ", "", "b", "a", "c"})
require.NoError(t, err)
assert.Equal(t, []string{"a", "b", "c"}, got)
})
t.Run("allows max unique ids", func(t *testing.T) {
got, err := normalizeTemporaryAttachmentIDs([]string{"1", "2", "3", "4", "5"})
require.NoError(t, err)
assert.Equal(t, []string{"1", "2", "3", "4", "5"}, got)
})
}
@@ -0,0 +1,214 @@
package session
import (
"fmt"
"io"
"mime"
"net/http"
"path/filepath"
"strings"
apperrors "github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
"github.com/gin-gonic/gin"
)
// UploadTemporaryDocument accepts one multipart file and immediately returns
// a session-scoped document ID. Parsing continues in the document worker.
func (h *Handler) UploadTemporaryDocument(c *gin.Context) {
ctx := c.Request.Context()
sessionID := c.Param("session_id")
// Uploading attaches content to the session, so use the strict owner scope:
// a tenant admin may read an API-key session but must not add attachments.
if _, err := h.sessionService.GetOwnedSession(ctx, sessionID); err != nil {
c.Error(apperrors.NewNotFoundError("Session not found"))
return
}
maxBytes := secutils.GetMaxFileSizeMB()*1024*1024 + 1024*1024
c.Request.Body = http.MaxBytesReader(c.Writer, c.Request.Body, maxBytes)
fileHeader, err := c.FormFile("file")
if err != nil {
c.Error(apperrors.NewBadRequestError(fmt.Sprintf("invalid attachment upload: %v", err)))
return
}
file, err := fileHeader.Open()
if err != nil {
c.Error(apperrors.NewBadRequestError("failed to open attachment"))
return
}
defer file.Close()
sourceTenantID, parseErr := types.ParseAgentSourceTenantID(c.PostForm(types.AgentSourceTenantIDParam))
if parseErr != nil {
c.Error(apperrors.NewBadRequestError(parseErr.Error()))
return
}
agent, resourceTenantID, _ := h.resolveAgent(ctx, c, c.PostForm("agent_id"), sourceTenantID)
if sourceTenantID != 0 && agent == nil {
c.Error(apperrors.NewNotFoundError("Shared agent not found"))
return
}
ext := strings.TrimPrefix(strings.ToLower(filepath.Ext(fileHeader.Filename)), ".")
options := types.TemporaryDocumentCreateOptions{ParserEngine: strings.TrimSpace(c.PostForm("parser_engine"))}
if agent != nil {
options.ResourceTenantID = resourceTenantID
if len(agent.Config.SupportedFileTypes) > 0 && !containsFileType(agent.Config.SupportedFileTypes, ext) {
c.Error(apperrors.NewBadRequestError("file type is not supported by this agent"))
return
}
if isAudioExtension(ext) {
if !agent.Config.AudioUploadEnabled || agent.Config.ASRModelID == "" {
c.Error(apperrors.NewBadRequestError("audio upload is not enabled or no ASR model is configured"))
return
}
options.ASRModelID = agent.Config.ASRModelID
}
// Resolve the parser engine from the agent's chat rules when the caller
// did not pass an explicit engine. Tenant-level rules remain the final
// fallback and are applied in the parse worker.
if options.ParserEngine == "" || options.ParserEngine == "auto" {
if engine := agent.Config.ResolveChatParserEngine(ext); engine != "" {
options.ParserEngine = engine
}
}
// Image understanding (caption/OCR) uses the agent's VLM model. Images
// always benefit; scanned/image-only documents only OCR when the agent
// enabled AttachmentImageUnderstanding (see parse worker threshold gate).
if agent.Config.ImageUploadEnabled && agent.Config.VLMModelID != "" {
options.VLMModelID = agent.Config.VLMModelID
options.ImageUnderstanding = agent.Config.AttachmentImageUnderstanding
options.OCRMaxPages = agent.Config.AttachmentOCRMaxPages
}
}
document, err := h.temporaryDocuments.Create(
ctx, c.GetUint64(types.TenantIDContextKey.String()), sessionID,
fileHeader.Filename, fileHeader.Header.Get("Content-Type"), fileHeader.Size, file, options,
)
if err != nil {
c.Error(apperrors.NewBadRequestError(err.Error()))
return
}
c.JSON(http.StatusAccepted, gin.H{"success": true, "data": document})
}
func (h *Handler) ListTemporaryDocuments(c *gin.Context) {
ctx := c.Request.Context()
sessionID := sessionIDParam(c)
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
c.Error(apperrors.NewNotFoundError("Session not found"))
return
}
documents, err := h.temporaryDocuments.List(ctx, c.GetUint64(types.TenantIDContextKey.String()), sessionID)
if err != nil {
c.Error(apperrors.NewInternalServerError(err.Error()))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": documents})
}
func (h *Handler) GetTemporaryDocument(c *gin.Context) {
ctx := c.Request.Context()
sessionID := sessionIDParam(c)
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
c.Error(apperrors.NewNotFoundError("Session not found"))
return
}
document, err := h.temporaryDocuments.Get(ctx, c.GetUint64(types.TenantIDContextKey.String()), sessionID, c.Param("attachment_id"))
if err != nil {
c.Error(apperrors.NewInternalServerError(err.Error()))
return
}
if document == nil {
c.Error(apperrors.NewNotFoundError("Attachment not found"))
return
}
c.JSON(http.StatusOK, gin.H{"success": true, "data": document})
}
func (h *Handler) PreviewTemporaryDocument(c *gin.Context) {
ctx := c.Request.Context()
sessionID := sessionIDParam(c)
if _, err := h.sessionService.GetSession(ctx, sessionID); err != nil {
c.Error(apperrors.NewNotFoundError("Session not found"))
return
}
attachmentID := secutils.SanitizeForLog(c.Param("attachment_id"))
if attachmentID == "" {
c.Error(apperrors.NewBadRequestError("Attachment ID cannot be empty"))
return
}
file, filename, err := h.temporaryDocuments.OpenFile(
ctx, c.GetUint64(types.TenantIDContextKey.String()), sessionID, attachmentID,
)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "not found") {
c.Error(apperrors.NewNotFoundError("Attachment not found"))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(apperrors.NewInternalServerError("Failed to retrieve attachment").WithDetails(err.Error()))
return
}
defer file.Close()
contentType, inline := secutils.SafeContentTypeByFilename(filename)
c.Header("Content-Type", contentType)
c.Header("X-Content-Type-Options", "nosniff")
disposition := "inline"
if !inline {
disposition = "attachment"
}
c.Header("Content-Disposition", mime.FormatMediaType(disposition, map[string]string{"filename": filename}))
c.Header("Cache-Control", "private, max-age=3600")
c.Stream(func(w io.Writer) bool {
if _, err := io.Copy(w, file); err != nil {
logger.Errorf(ctx, "Failed to stream attachment preview: %v", err)
return false
}
return false
})
}
func (h *Handler) DeleteTemporaryDocument(c *gin.Context) {
ctx := c.Request.Context()
sessionID := sessionIDParam(c)
// Deleting mutates the session's attachments, so use the strict owner scope:
// a tenant admin may read an API-key session but must not remove attachments.
if _, err := h.sessionService.GetOwnedSession(ctx, sessionID); err != nil {
c.Error(apperrors.NewNotFoundError("Session not found"))
return
}
if err := h.temporaryDocuments.Delete(ctx, c.GetUint64(types.TenantIDContextKey.String()), sessionID, c.Param("attachment_id")); err != nil {
c.Error(apperrors.NewInternalServerError(err.Error()))
return
}
c.Status(http.StatusNoContent)
}
func sessionIDParam(c *gin.Context) string {
if value := c.Param("session_id"); value != "" {
return value
}
return c.Param("id")
}
func containsFileType(supported []string, ext string) bool {
for _, item := range supported {
if strings.TrimPrefix(strings.ToLower(strings.TrimSpace(item)), ".") == ext {
return true
}
}
return false
}
func isAudioExtension(ext string) bool {
switch ext {
case "mp3", "wav", "m4a", "flac", "ogg", "aac":
return true
default:
return false
}
}
+76
View File
@@ -0,0 +1,76 @@
package session
import (
stderrors "errors"
"net/http"
"github.com/Tencent/WeKnora/internal/errors"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/gin-gonic/gin"
)
// GenerateTitle godoc
// @Summary 生成会话标题
// @Description 根据消息内容自动生成会话标题
// @Tags 会话
// @Accept json
// @Produce json
// @Param session_id path string true "会话ID"
// @Param request body GenerateTitleRequest true "生成请求"
// @Success 200 {object} map[string]interface{} "生成的标题"
// @Failure 400 {object} errors.AppError "请求参数错误"
// @Security Bearer
// @Security ApiKeyAuth
// @Router /sessions/{session_id}/title [post]
func (h *Handler) GenerateTitle(c *gin.Context) {
ctx := c.Request.Context()
logger.Info(ctx, "Start generating session title")
// Get session ID from URL parameter
sessionID := c.Param("session_id")
if sessionID == "" {
logger.Error(ctx, "Session ID is empty")
c.Error(errors.NewBadRequestError(errors.ErrInvalidSessionID.Error()))
return
}
// Parse request body
var request GenerateTitleRequest
if err := c.ShouldBindJSON(&request); err != nil {
logger.Error(ctx, "Failed to parse request data", err)
c.Error(errors.NewBadRequestError(err.Error()))
return
}
// Get session from database. Title generation writes the session row, so use
// the strict owner scope: a tenant admin may read an API-key session but must
// not be able to (re)generate its title.
session, err := h.sessionService.GetOwnedSession(ctx, sessionID)
if err != nil {
if stderrors.Is(err, errors.ErrSessionNotFound) {
logger.Warnf(ctx, "Session not found, ID: %s", sessionID)
c.Error(errors.NewNotFoundError(err.Error()))
return
}
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Call service to generate title
logger.Infof(ctx, "Generating session title, session ID: %s, message count: %d", sessionID, len(request.Messages))
title, err := h.sessionService.GenerateTitle(ctx, session, request.Messages, "")
if err != nil {
logger.ErrorWithFields(ctx, err, nil)
c.Error(errors.NewInternalServerError(err.Error()))
return
}
// Return generated title
logger.Infof(ctx, "Session title generated successfully, session ID: %s, title: %s", sessionID, title)
c.JSON(http.StatusOK, gin.H{
"success": true,
"data": title,
})
}
+85
View File
@@ -0,0 +1,85 @@
package session
import (
"github.com/Tencent/WeKnora/internal/types"
)
// CreateSessionRequest represents a request to create a new session
// Sessions are now knowledge-base-independent and serve as conversation containers.
// All configuration (knowledge bases, model settings, etc.) comes from custom agent at query time.
type CreateSessionRequest struct {
// Title for the session (optional)
Title string `json:"title"`
// Description for the session (optional)
Description string `json:"description"`
}
// GenerateTitleRequest defines the request structure for generating a session title
type GenerateTitleRequest struct {
Messages []types.Message `json:"messages" binding:"required"` // Messages to use as context for title generation
}
// MentionedItemRequest represents a mentioned item in the request
type MentionedItemRequest struct {
ID string `json:"id"`
Name string `json:"name"`
Type string `json:"type"` // "kb", "file", "tag", "mcp", "skill"
KBType string `json:"kb_type"` // "document" or "faq" (only for kb type)
KBID string `json:"kb_id"` // Parent knowledge base for file/tag mentions
KBName string `json:"kb_name"` // Display name for parent KB
ServiceID string `json:"service_id"` // Parent MCP service for MCP tool mentions
SkillName string `json:"skill_name"` // Preloaded agent skill name
}
// ImageAttachment represents an image in a chat request.
// Frontend sends base64 data in the Data field; the backend saves, runs VLM analysis,
// and populates URL/Caption before proceeding with the chat pipeline.
type ImageAttachment struct {
Data string `json:"data,omitempty"` // base64 data URI from frontend (data:image/png;base64,...)
URL string `json:"url,omitempty"` // serving URL after saving to storage
Caption string `json:"caption,omitempty"` // VLM analysis result (context-aware, single call)
}
// CreateKnowledgeQARequest defines the request structure for knowledge QA
type CreateKnowledgeQARequest struct {
Query string `json:"query" binding:"required"` // Query text for knowledge base search
KnowledgeBaseIDs []string `json:"knowledge_base_ids"` // Selected knowledge base ID for this request
KnowledgeIds []string `json:"knowledge_ids"` // Selected knowledge ID for this request
AgentEnabled bool `json:"agent_enabled"` // Whether agent mode is enabled for this request
AgentID string `json:"agent_id"` // Selected custom agent ID (backend resolves shared agent and its workspace from share relation)
AgentSourceTenantID uint64 `json:"agent_source_tenant_id,omitempty"` // Optional disambiguator; backend still verifies the share relation
WebSearchEnabled bool `json:"web_search_enabled"` // Whether web search is enabled for this request
SummaryModelID string `json:"summary_model_id"` // Optional summary model ID for this request (overrides session default)
MCPServiceIDs []string `json:"mcp_service_ids"` // Per-request MCP services selected via @mention
SkillNames []string `json:"skill_names"` // Per-request Skills selected via @mention
TagIDs []string `json:"tag_ids"` // @mentioned tag IDs (display/debug; scoped via MentionedItems)
MentionedItems []MentionedItemRequest `json:"mentioned_items"` // @mentioned knowledge bases and files
DisableTitle bool `json:"disable_title"` // Whether to disable auto title generation
Images []ImageAttachment `json:"images"` // Attached images for multimodal chat
AttachmentUploads []AttachmentUpload `json:"attachment_uploads,omitempty"` // Attached files (documents, audio, etc.)
AttachmentIDs []string `json:"attachment_ids,omitempty"` // Pre-uploaded session-scoped document IDs
Channel string `json:"channel"` // Source channel: "web", "api", "im", etc.
SuggestionAttribution *types.SuggestionAttribution `json:"suggestion_attribution,omitempty"`
}
// AttachmentUpload represents a file attachment upload from the client
type AttachmentUpload struct {
Data string `json:"data"` // Base64-encoded file content
FileName string `json:"file_name"` // Original filename
FileSize int64 `json:"file_size"` // File size in bytes
}
// SearchKnowledgeRequest defines the request structure for searching knowledge without LLM summarization
type SearchKnowledgeRequest struct {
Query string `json:"query" binding:"required"` // Query text to search for
KnowledgeBaseID string `json:"knowledge_base_id"` // Single knowledge base ID (for backward compatibility)
KnowledgeBaseIDs []string `json:"knowledge_base_ids"` // IDs of knowledge bases to search (multi-KB support)
KnowledgeIDs []string `json:"knowledge_ids"` // IDs of specific knowledge (files) to search
TagIDs []string `json:"tag_ids"` // Tag IDs for filtering within a single KB
MentionedItems []MentionedItemRequest `json:"mentioned_items"` // Optional scoped tag mentions
}
// StopSessionRequest represents the stop session request
type StopSessionRequest struct {
MessageID string `json:"message_id" binding:"required"`
}
@@ -0,0 +1,80 @@
package session
import (
"context"
"github.com/Tencent/WeKnora/internal/logger"
"github.com/Tencent/WeKnora/internal/types"
secutils "github.com/Tencent/WeKnora/internal/utils"
)
type wikiFixerKBLookup interface {
GetKnowledgeBaseByIDOnly(ctx context.Context, id string) (*types.KnowledgeBase, error)
}
type wikiFixerKBSharePermission interface {
CheckTenantKBPermission(ctx context.Context, kbID string, callerTenantID uint64, callerTenantRole types.TenantRole) (types.OrgMemberRole, bool, error)
}
func (h *Handler) resolveWikiFixerTenantScope(
ctx context.Context,
agent *types.CustomAgent,
currentTenantID uint64,
callerTenantRole types.TenantRole,
kbIDs []string,
) (*types.CustomAgent, uint64) {
return resolveBuiltinWikiFixerTenantScope(
ctx,
agent,
currentTenantID,
callerTenantRole,
kbIDs,
h.knowledgebaseService,
h.kbShareService,
)
}
func resolveBuiltinWikiFixerTenantScope(
ctx context.Context,
agent *types.CustomAgent,
currentTenantID uint64,
callerTenantRole types.TenantRole,
kbIDs []string,
kbLookup wikiFixerKBLookup,
kbShare wikiFixerKBSharePermission,
) (*types.CustomAgent, uint64) {
if agent == nil || agent.ID != types.BuiltinWikiFixerID {
return agent, 0
}
if currentTenantID == 0 || len(kbIDs) != 1 || kbLookup == nil || kbShare == nil {
return agent, 0
}
kbID := kbIDs[0]
kb, err := kbLookup.GetKnowledgeBaseByIDOnly(ctx, kbID)
if err != nil {
logger.Warnf(ctx, "wiki fixer: failed to resolve KB %s for shared scope: %v", secutils.SanitizeForLog(kbID), err)
return agent, 0
}
if kb == nil {
logger.Warnf(ctx, "wiki fixer: KB %s not found for shared scope", secutils.SanitizeForLog(kbID))
return agent, 0
}
if kb.TenantID == 0 || kb.TenantID == currentTenantID {
return agent, 0
}
permission, isShared, err := kbShare.CheckTenantKBPermission(ctx, kb.ID, currentTenantID, callerTenantRole)
if err != nil {
logger.Warnf(ctx, "wiki fixer: failed to check shared KB %s permission: %v", secutils.SanitizeForLog(kb.ID), err)
return agent, 0
}
if !isShared || !permission.HasPermission(types.OrgRoleEditor) {
return agent, 0
}
scopedAgent := *agent
scopedAgent.TenantID = kb.TenantID
logger.Infof(ctx, "wiki fixer: using shared KB source tenant %d for KB %s", kb.TenantID, secutils.SanitizeForLog(kb.ID))
return &scopedAgent, kb.TenantID
}
@@ -0,0 +1,177 @@
package session
import (
"context"
"errors"
"testing"
"github.com/Tencent/WeKnora/internal/types"
"github.com/stretchr/testify/require"
)
type wikiFixerKBLookupStub struct {
kb *types.KnowledgeBase
err error
calledWith string
}
func (s *wikiFixerKBLookupStub) GetKnowledgeBaseByIDOnly(_ context.Context, id string) (*types.KnowledgeBase, error) {
s.calledWith = id
return s.kb, s.err
}
type wikiFixerKBShareStub struct {
permission types.OrgMemberRole
isShared bool
err error
checkedKBID string
checkedTenantID uint64
checkedTenantRole types.TenantRole
}
func (s *wikiFixerKBShareStub) CheckTenantKBPermission(
_ context.Context,
kbID string,
callerTenantID uint64,
callerTenantRole types.TenantRole,
) (types.OrgMemberRole, bool, error) {
s.checkedKBID = kbID
s.checkedTenantID = callerTenantID
s.checkedTenantRole = callerTenantRole
return s.permission, s.isShared, s.err
}
func TestResolveBuiltinWikiFixerTenantScope_SharedEditorUsesSourceTenant(t *testing.T) {
agent := &types.CustomAgent{ID: types.BuiltinWikiFixerID, TenantID: 10, Name: "Wiki Fixer"}
kbLookup := &wikiFixerKBLookupStub{
kb: &types.KnowledgeBase{ID: "kb-shared", TenantID: 20, Name: "Shared KB"},
}
kbShare := &wikiFixerKBShareStub{
permission: types.OrgRoleEditor,
isShared: true,
}
gotAgent, effectiveTenantID := resolveBuiltinWikiFixerTenantScope(
context.Background(),
agent,
10,
types.TenantRoleContributor,
[]string{"kb-shared"},
kbLookup,
kbShare,
)
require.NotSame(t, agent, gotAgent)
require.Equal(t, uint64(20), gotAgent.TenantID)
require.Equal(t, uint64(20), effectiveTenantID)
require.Equal(t, uint64(10), agent.TenantID, "must not mutate the cached built-in agent")
require.Equal(t, "kb-shared", kbLookup.calledWith)
require.Equal(t, "kb-shared", kbShare.checkedKBID)
require.Equal(t, uint64(10), kbShare.checkedTenantID)
require.Equal(t, types.TenantRoleContributor, kbShare.checkedTenantRole)
}
func TestResolveBuiltinWikiFixerTenantScope_SharedViewerDoesNotSwitchTenant(t *testing.T) {
agent := &types.CustomAgent{ID: types.BuiltinWikiFixerID, TenantID: 10}
kbLookup := &wikiFixerKBLookupStub{
kb: &types.KnowledgeBase{ID: "kb-shared", TenantID: 20},
}
kbShare := &wikiFixerKBShareStub{
permission: types.OrgRoleViewer,
isShared: true,
}
gotAgent, effectiveTenantID := resolveBuiltinWikiFixerTenantScope(
context.Background(),
agent,
10,
types.TenantRoleContributor,
[]string{"kb-shared"},
kbLookup,
kbShare,
)
require.Same(t, agent, gotAgent)
require.Zero(t, effectiveTenantID)
require.Equal(t, uint64(10), gotAgent.TenantID)
}
func TestResolveBuiltinWikiFixerTenantScope_IgnoresNonWikiFixerAgents(t *testing.T) {
agent := &types.CustomAgent{ID: "custom-agent", TenantID: 10}
kbLookup := &wikiFixerKBLookupStub{
kb: &types.KnowledgeBase{ID: "kb-shared", TenantID: 20},
}
kbShare := &wikiFixerKBShareStub{
permission: types.OrgRoleEditor,
isShared: true,
}
gotAgent, effectiveTenantID := resolveBuiltinWikiFixerTenantScope(
context.Background(),
agent,
10,
types.TenantRoleContributor,
[]string{"kb-shared"},
kbLookup,
kbShare,
)
require.Same(t, agent, gotAgent)
require.Zero(t, effectiveTenantID)
require.Empty(t, kbLookup.calledWith)
}
func TestResolveBuiltinWikiFixerTenantScope_RequiresSingleKnowledgeBase(t *testing.T) {
agent := &types.CustomAgent{ID: types.BuiltinWikiFixerID, TenantID: 10}
kbLookup := &wikiFixerKBLookupStub{
kb: &types.KnowledgeBase{ID: "kb-shared", TenantID: 20},
}
gotAgent, effectiveTenantID := resolveBuiltinWikiFixerTenantScope(
context.Background(),
agent,
10,
types.TenantRoleContributor,
[]string{"kb-a", "kb-b"},
kbLookup,
&wikiFixerKBShareStub{permission: types.OrgRoleEditor, isShared: true},
)
require.Same(t, agent, gotAgent)
require.Zero(t, effectiveTenantID)
require.Empty(t, kbLookup.calledWith)
}
func TestResolveBuiltinWikiFixerTenantScope_FallsBackOnLookupOrPermissionErrors(t *testing.T) {
agent := &types.CustomAgent{ID: types.BuiltinWikiFixerID, TenantID: 10}
t.Run("kb lookup error", func(t *testing.T) {
gotAgent, effectiveTenantID := resolveBuiltinWikiFixerTenantScope(
context.Background(),
agent,
10,
types.TenantRoleContributor,
[]string{"kb-shared"},
&wikiFixerKBLookupStub{err: errors.New("lookup failed")},
&wikiFixerKBShareStub{permission: types.OrgRoleEditor, isShared: true},
)
require.Same(t, agent, gotAgent)
require.Zero(t, effectiveTenantID)
})
t.Run("permission check error", func(t *testing.T) {
gotAgent, effectiveTenantID := resolveBuiltinWikiFixerTenantScope(
context.Background(),
agent,
10,
types.TenantRoleContributor,
[]string{"kb-shared"},
&wikiFixerKBLookupStub{kb: &types.KnowledgeBase{ID: "kb-shared", TenantID: 20}},
&wikiFixerKBShareStub{err: errors.New("permission failed")},
)
require.Same(t, agent, gotAgent)
require.Zero(t, effectiveTenantID)
})
}