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
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:
@@ -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 (``).
|
||||
// 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:
|
||||
//
|
||||
// 
|
||||
// 
|
||||
// 
|
||||
//
|
||||
// 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 (``) 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: " 成交量_838ccc.html)",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "file name with spaces and parentheses, no prefix",
|
||||
content: " 成交量_838ccc.html)",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "bare file name in image",
|
||||
content: "",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "sandbox prefix",
|
||||
content: "",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "sandbox scheme with slashes",
|
||||
content: "",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "directory prefix is dropped",
|
||||
content: "[榜单](/workspace/output/concept_ranking.csv)",
|
||||
want: "[榜单](" + refFor(1) + ")",
|
||||
},
|
||||
{
|
||||
name: "percent-encoded name",
|
||||
content: "",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "title is preserved",
|
||||
content: ``,
|
||||
want: ` + ` "走势")`,
|
||||
},
|
||||
{
|
||||
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: " + ")",
|
||||
want: " + ")",
|
||||
},
|
||||
{
|
||||
name: "prose parentheses are not link destinations",
|
||||
content: "腾讯控股(00700) 的成交量见下图。",
|
||||
want: "腾讯控股(00700) 的成交量见下图。",
|
||||
},
|
||||
{
|
||||
name: "unknown file name untouched",
|
||||
content: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "http url untouched",
|
||||
content: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "knowledge base image untouched",
|
||||
content: "",
|
||||
want: "",
|
||||
},
|
||||
{
|
||||
name: "fenced code untouched",
|
||||
content: "```\n\n```",
|
||||
want: "```\n\n```",
|
||||
},
|
||||
{
|
||||
name: "inline code untouched",
|
||||
content: "写成 `` 即可",
|
||||
want: "写成 `` 即可",
|
||||
},
|
||||
{
|
||||
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\n\n数据见 [表格](sandbox:data.csv)," +
|
||||
"外链 [文档](https://example.com/chart.html) 不受影响。"
|
||||
want := "## 图表\n\n + ")\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 := "\n\n"
|
||||
want := "\n\n + ")"
|
||||
|
||||
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("", artifacts)
|
||||
if want := ""; 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 := ""
|
||||
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  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 ",
|
||||
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, ` shows the flow.`)
|
||||
|
||||
// The references payload carries both the chunk text and image_info.
|
||||
assert.Contains(t, body, `figure `)
|
||||
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")
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
@@ -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 ",
|
||||
}
|
||||
|
||||
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 
|
||||
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, " 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: "
|
||||
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, "", 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 ",
|
||||
}, "req-1", publicStreamRewriter())
|
||||
assert.Equal(t, "chart ", 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 "}
|
||||
evt := interfaces.StreamEvent{
|
||||
ID: "refs-1",
|
||||
Type: types.ResponseTypeReferences,
|
||||
Data: map[string]interface{}{
|
||||
"references": types.References{reference},
|
||||
"output": "chart ",
|
||||
},
|
||||
}
|
||||
|
||||
response := buildStreamResponseFor(context.Background(), evt, "req-1", publicStreamRewriter())
|
||||
|
||||
require.Len(t, response.KnowledgeReferences, 1)
|
||||
assert.Equal(t, "chunk ",
|
||||
response.KnowledgeReferences[0].Content)
|
||||
assert.Equal(t, "chunk ", reference.Content,
|
||||
"the replay buffer's SearchResult must be untouched")
|
||||
assert.Equal(t, "chart ", evt.Data["output"],
|
||||
"the replay buffer's metadata map must be untouched")
|
||||
assert.Equal(t, "chart ", 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 
|
||||
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, `
|
||||
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 
|
||||
|
||||
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, `
|
||||
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 , 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, `
|
||||
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 ",
|
||||
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)
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
})
|
||||
}
|
||||
@@ -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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user