// // Copyright 2026 The InfiniFlow Authors. All Rights Reserved. // // Licensed under the Apache License, Version 2.0 (the "License"); // you may not use this file except in compliance with the License. // You may obtain a copy of the License at // // http://www.apache.org/licenses/LICENSE-2.0 // // Unless required by applicable law or agreed to in writing, software // distributed under the License is distributed on an "AS IS" BASIS, // WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. // See the License for the specific language governing permissions and // limitations under the License. // package service import ( "encoding/json" "errors" "fmt" "sort" "strconv" "strings" "time" "github.com/google/uuid" "gorm.io/gorm" "ragflow/internal/common" "ragflow/internal/dao" "ragflow/internal/entity" ) const ( agentTagsFieldMax = 512 agentTagMaxLen = 64 ) // ListAgentSessionsRequest are the parameters for ListAgentSessions. type ListAgentSessionsRequest struct { SessionID string UserID string Page int PageSize int Keywords string FromDate string ToDate string OrderBy string Desc bool ExpUserID string IncludeDSL bool } // ListAgentSessionsResponse is the response body for ListAgentSessions. type ListAgentSessionsResponse struct { Data []map[string]interface{} `json:"data"` Total int64 `json:"total"` } // DeleteAgentSessionsResult wraps DeleteAgentSessionsResponse with a message. type DeleteAgentSessionsResult struct { Data *DeleteAgentSessionsResponse Message string } // DeleteAgentSessionsResponse summarises a multi-id delete. type DeleteAgentSessionsResponse struct { SuccessCount int `json:"success_count"` Errors []string `json:"errors,omitempty"` } // CheckCanvasAccess returns true when the user is the canvas owner or // holds team-level permission for the owner's tenant. func (s *AgentService) CheckCanvasAccess(userID, canvasID string) (bool, error) { canvas, err := s.canvasDAO.GetByID(canvasID) if err != nil { return false, err } if canvas.UserID == userID { return true, nil } if canvas.Permission != string(entity.TenantPermissionTeam) { return false, nil } tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) if err != nil { return false, err } for _, tid := range tenantIDs { if canvas.UserID == tid { return true, nil } } return false, nil } func parseAgentSessionDate(value string, isEnd bool) (*time.Time, error) { if value == "" { return nil, nil } if strings.Contains(value, "T") { normalized := strings.ReplaceAll(value, "Z", "+00:00") parsed, err := time.Parse(time.RFC3339, normalized) if err != nil { return nil, err } local := parsed.Local() return &local, nil } if len(value) == 10 { if isEnd { value += " 23:59:59" } else { value += " 00:00:00" } } parsed, err := time.ParseInLocation("2006-01-02 15:04:05", value, time.Local) if err != nil { return nil, err } return &parsed, nil } func normalizeAgentSession(session *entity.API4Conversation, includeDSL bool) map[string]interface{} { messages := parseAgentSessionMessages(session.Message) references := parseAgentSessionReferences(session.Reference) for _, message := range messages { delete(message, "prompt") } if len(references) > 0 { assistantMessages := make([]map[string]interface{}, 0) for i, message := range messages { role, _ := message["role"].(string) if i != 0 && role != "user" { assistantMessages = append(assistantMessages, message) } } for i := 0; i < len(assistantMessages) && i < len(references); i++ { rawChunks, _ := references[i]["chunks"].([]interface{}) assistantMessages[i]["reference"] = normalizeAgentReferenceChunks(rawChunks) } } result := map[string]interface{}{ "id": session.ID, "name": session.Name, "agent_id": session.DialogID, "user_id": session.UserID, "exp_user_id": session.ExpUserID, "message": messages, "tokens": session.Tokens, "source": session.Source, "duration": session.Duration, "round": session.Round, "thumb_up": session.ThumbUp, "errors": session.Errors, "version_title": session.VersionTitle, "create_time": session.CreateTime, "create_date": session.CreateDate, "update_time": session.UpdateTime, "update_date": session.UpdateDate, } if includeDSL { result["dsl"] = session.DSL } return result } func parseAgentSessionReferences(raw json.RawMessage) []map[string]interface{} { if len(raw) == 0 { return []map[string]interface{}{} } var references []map[string]interface{} if err := json.Unmarshal(raw, &references); err == nil { for i, reference := range references { references[i] = normalizeAgentReferenceEntry(reference) } return references } var referenceMap map[string]interface{} if err := json.Unmarshal(raw, &referenceMap); err != nil { return []map[string]interface{}{} } if _, ok := referenceMap["chunks"]; ok { return []map[string]interface{}{normalizeAgentReferenceEntry(referenceMap)} } keys := make([]string, 0, len(referenceMap)) for key := range referenceMap { keys = append(keys, key) } sort.Slice(keys, func(i, j int) bool { left, _ := strconv.Atoi(keys[i]) right, _ := strconv.Atoi(keys[j]) return left < right }) result := make([]map[string]interface{}, 0, len(keys)) for _, key := range keys { reference, ok := referenceMap[key].(map[string]interface{}) if !ok { continue } result = append(result, normalizeAgentReferenceEntry(reference)) } return result } func parseAgentSessionMessages(raw json.RawMessage) []map[string]interface{} { if len(raw) == 0 { return []map[string]interface{}{} } var messages []map[string]interface{} if err := json.Unmarshal(raw, &messages); err != nil { return []map[string]interface{}{} } return messages } func normalizeAgentReferenceEntry(reference map[string]interface{}) map[string]interface{} { if reference == nil { return map[string]interface{}{ "chunks": []interface{}{}, "doc_aggs": []interface{}{}, } } if _, ok := reference["chunks"]; ok { return map[string]interface{}{ "chunks": valueOrEmptySlice(reference["chunks"]), "doc_aggs": valueOrEmptySlice(reference["doc_aggs"]), } } if _, ok := reference["doc_aggs"]; ok { return map[string]interface{}{ "chunks": valueOrEmptySlice(reference["chunks"]), "doc_aggs": valueOrEmptySlice(reference["doc_aggs"]), } } return map[string]interface{}{ "chunks": valueOrEmptySlice(reference["reference"]), "doc_aggs": valueOrEmptySlice(reference["doc_aggs"]), } } func valueOrEmptySlice(value interface{}) interface{} { if value == nil { return []interface{}{} } return value } func normalizeAgentReferenceChunks(chunks []interface{}) []map[string]interface{} { result := make([]map[string]interface{}, 0, len(chunks)) for _, rawChunk := range chunks { chunk, ok := rawChunk.(map[string]interface{}) if !ok { continue } result = append(result, map[string]interface{}{ "id": firstNonNil(chunk["chunk_id"], chunk["id"]), "content": firstNonNil(chunk["content_with_weight"], chunk["content"]), "document_id": firstNonNil(chunk["doc_id"], chunk["document_id"]), "document_name": firstNonNil(chunk["docnm_kwd"], chunk["document_name"]), "dataset_id": firstNonNil(chunk["kb_id"], chunk["dataset_id"]), "image_id": firstNonNil(chunk["image_id"], chunk["img_id"]), "positions": firstNonNil(chunk["positions"], chunk["position_int"]), }) } return result } func firstNonNil(values ...interface{}) interface{} { for _, value := range values { if value != nil { return value } } return nil } // checkDuplicateSessionIDs returns the de-duplicated id list and a slice of // human-readable duplicate messages. func checkDuplicateSessionIDs(ids []string) ([]string, []string) { seen := make(map[string]int, len(ids)) uniqueIDs := make([]string, 0, len(ids)) for _, id := range ids { id = strings.TrimSpace(id) seen[id]++ if seen[id] == 1 { uniqueIDs = append(uniqueIDs, id) } } duplicateMessages := make([]string, 0) for _, id := range uniqueIDs { if seen[id] > 1 { duplicateMessages = append(duplicateMessages, fmt.Sprintf("Duplicate session ids: %s", id)) } } return uniqueIDs, duplicateMessages } // ListAgentSessions returns paginated agent sessions visible to the caller. func (s *AgentService) ListAgentSessions(userID, tenantID, agentID string, req ListAgentSessionsRequest) (*ListAgentSessionsResponse, common.ErrorCode, error) { if agentID == "" { return nil, common.CodeArgumentError, errors.New("agent_id is required") } ok, err := s.CheckCanvasAccess(userID, agentID) if err != nil { // The Python agent API folds "canvas does not exist" into // the same 103 access envelope as a permission mismatch // (see @_require_canvas_access_async). Surface that // shape here instead of a 500 record not found so the // front-end does not log a server error for unknown ids. if errors.Is(err, dao.ErrUserCanvasNotFound) { return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.") } return nil, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err) } if !ok { return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.") } sessionDAO := dao.NewChatSessionDAO() if req.ExpUserID != "" { rows, err := sessionDAO.ListAgentSessionNames(agentID, req.ExpUserID) if err != nil { return nil, common.CodeServerError, err } return &ListAgentSessionsResponse{Data: rows, Total: int64(len(rows))}, common.CodeSuccess, nil } fromDate, err := parseAgentSessionDate(req.FromDate, false) if err != nil { return nil, common.CodeArgumentError, err } toDate, err := parseAgentSessionDate(req.ToDate, true) if err != nil { return nil, common.CodeArgumentError, err } total, sessions, err := sessionDAO.ListAgentSessions(dao.ListAgentSessionsParams{ AgentID: agentID, Page: req.Page, PageSize: req.PageSize, OrderBy: req.OrderBy, Desc: req.Desc, SessionID: req.SessionID, UserID: req.UserID, IncludeDSL: req.IncludeDSL, Keywords: req.Keywords, FromDate: fromDate, ToDate: toDate, ExpUserID: req.ExpUserID, }) if err != nil { return nil, common.CodeServerError, err } data := make([]map[string]interface{}, 0, len(sessions)) for _, session := range sessions { data = append(data, normalizeAgentSession(session, req.IncludeDSL)) } return &ListAgentSessionsResponse{Data: data, Total: total}, common.CodeSuccess, nil } // GetAgentSession fetches a single conversation belonging to agentID. func (s *AgentService) GetAgentSession(userID, agentID, sessionID string) (*entity.API4Conversation, common.ErrorCode, error) { if sessionID == "" { return nil, common.CodeArgumentError, fmt.Errorf("session_id is required") } ok, err := s.CheckCanvasAccess(userID, agentID) if err != nil { if errors.Is(err, dao.ErrUserCanvasNotFound) { return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.") } return nil, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err) } if !ok { return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.") } data, err := s.api4ConversationDAO.GetBySessionID(sessionID, agentID) if err != nil { return nil, common.CodeServerError, fmt.Errorf("failed to fetch session: %w", err) } if data == nil { return nil, common.CodeNotFound, fmt.Errorf("agent session not found") } return data, common.CodeSuccess, nil } // DeleteAgentSessionItem removes one conversation if it belongs to agentID. func (s *AgentService) DeleteAgentSessionItem(userID, agentID, sessionID string) (bool, common.ErrorCode, error) { if sessionID == "" { return false, common.CodeArgumentError, errors.New("session_id is required") } ok, err := s.CheckCanvasAccess(userID, agentID) if err != nil { if errors.Is(err, dao.ErrUserCanvasNotFound) { return false, common.CodeOperatingError, errors.New("Agent not found or no permission.") } return false, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err) } if !ok { return false, common.CodeOperatingError, errors.New("Agent not found or no permission.") } row, err := s.api4ConversationDAO.DeleteBySessionIDAndAgentID(sessionID, agentID) if err != nil { return false, common.CodeServerError, err } if row == 0 { return false, common.CodeSuccess, nil } return true, common.CodeSuccess, nil } // DeleteAgentSessions removes multiple conversations owned by agentID. // When ids is empty and deleteAll is true, every session under agentID is // removed. func (s *AgentService) DeleteAgentSessions(userID, agentID string, ids []string, deleteAll bool) (*DeleteAgentSessionsResult, common.ErrorCode, error) { if agentID == "" { return nil, common.CodeArgumentError, errors.New("agent_id is required") } // Owner-only by design: batch session deletion is destructive and must // not be available to team members even when the canvas has team // permission. CheckCanvasAccess (used elsewhere) would also allow team // access, which is too permissive for this operation. canvas, err := s.canvasDAO.GetByID(agentID) if err != nil || canvas == nil || canvas.UserID != userID { return nil, common.CodeDataError, fmt.Errorf("You don't own the agent %s", agentID) } if len(ids) == 0 { if !deleteAll { return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil } ids, err = s.api4ConversationDAO.ListIDsByAgentID(agentID) if err != nil { return nil, common.CodeServerError, err } if len(ids) == 0 { return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil } } sessionIDs, duplicateMessages := checkDuplicateSessionIDs(ids) errorsList := make([]string, 0) successCount := 0 for _, sessionID := range sessionIDs { sessionID = strings.TrimSpace(sessionID) if sessionID == "" { errorsList = append(errorsList, "Session ID is empty") continue } conv, err := s.api4ConversationDAO.GetBySessionID(sessionID, agentID) if err != nil { return nil, common.CodeServerError, err } if conv == nil { errorsList = append(errorsList, fmt.Sprintf("The agent doesn't own the session %s", sessionID)) continue } if _, err := s.api4ConversationDAO.DeleteBySessionIDAndAgentID(sessionID, agentID); err != nil { return nil, common.CodeServerError, err } successCount++ } if len(errorsList) > 0 { if successCount > 0 { return &DeleteAgentSessionsResult{ Message: fmt.Sprintf("Partially deleted %d sessions with %d errors", successCount, len(errorsList)), Data: &DeleteAgentSessionsResponse{ SuccessCount: successCount, Errors: errorsList, }, }, common.CodeSuccess, nil } return nil, common.CodeDataError, errors.New(strings.Join(errorsList, "; ")) } if len(duplicateMessages) > 0 { return &DeleteAgentSessionsResult{ Message: fmt.Sprintf("Partially deleted %d sessions with %d errors", successCount, len(duplicateMessages)), Data: &DeleteAgentSessionsResponse{ SuccessCount: successCount, Errors: duplicateMessages, }, }, common.CodeSuccess, nil } return &DeleteAgentSessionsResult{}, common.CodeSuccess, nil } // normalizeAgentTags returns an error for unsupported tag payload types. // The branch behaviour intentionally mirrors the Python implementation: // - string: treat the value as a CSV — split on "," and use each piece // as a separate tag ("alpha,beta" → ["alpha", "beta"]). // - []string / []interface{}: the caller already chose the boundary; // embedded commas are therefore replaced with spaces rather than // re-split (["alpha,beta"] → ["alpha beta"]). // // This asymmetry is required to keep tag handling byte-identical with // agent_api.update_agent in the Python service. func normalizeAgentTags(rawTags interface{}) (string, error) { cleaned := make([]string, 0) switch tags := rawTags.(type) { case nil: case string: for _, tag := range strings.Split(tags, ",") { tag = strings.TrimSpace(tag) if tag != "" { cleaned = append(cleaned, tag) } } case []string: for _, tag := range tags { tag = strings.TrimSpace(strings.ReplaceAll(tag, ",", " ")) if tag != "" { cleaned = append(cleaned, tag) } } case []interface{}: for _, value := range tags { if value == nil { continue } tag := strings.TrimSpace(strings.ReplaceAll(fmt.Sprint(value), ",", " ")) if tag != "" { cleaned = append(cleaned, tag) } } default: return "", fmt.Errorf("tags must be a string or array") } seen := make(map[string]struct{}, len(cleaned)) normalized := make([]string, 0, len(cleaned)) used := 0 for _, tag := range cleaned { tag = truncateRunes(tag, agentTagMaxLen) key := strings.ToLower(tag) if _, ok := seen[key]; ok { continue } extra := len([]rune(tag)) if len(normalized) > 0 { extra++ } if used+extra > agentTagsFieldMax { break } seen[key] = struct{}{} normalized = append(normalized, tag) used += extra } return strings.Join(normalized, ","), nil } func truncateRunes(value string, maxLen int) string { runes := []rune(value) if len(runes) <= maxLen { return value } return string(runes[:maxLen]) } // UpdateAgentTags normalises tags and persists them on a single canvas. func (s *AgentService) UpdateAgentTags(userID, canvasID string, tags interface{}) (bool, common.ErrorCode, error) { ok, err := s.CheckCanvasAccess(userID, canvasID) if err != nil { if errors.Is(err, dao.ErrUserCanvasNotFound) { return false, common.CodeOperatingError, errors.New("Agent not found or no permission.") } return false, common.CodeServerError, fmt.Errorf("failed to check agent permission: %w", err) } if !ok { return false, common.CodeOperatingError, errors.New("Agent not found or no permission.") } normalized, nErr := normalizeAgentTags(tags) if nErr != nil { return false, common.CodeBadRequest, nErr } rows, err := s.canvasDAO.UpdateTags(canvasID, normalized) if err != nil { return false, common.CodeServerError, fmt.Errorf("failed to update agent tags: %w", err) } if rows == 0 { if _, getErr := s.canvasDAO.GetByCanvasID(canvasID); getErr != nil { return false, common.CodeOperatingError, errors.New("Agent not found or no permission.") } return true, common.CodeSuccess, nil } return true, common.CodeSuccess, nil } // CreateAgentSessionRequest is the wire shape for POST // /api/v1/agents/:agent_id/sessions. type CreateAgentSessionRequest struct { UserID string AgentID string Name string Source string DSL json.RawMessage Messages json.RawMessage } // CreateAgentSession inserts a fresh conversation row tied to the // given agent canvas. The Phase 5 stub intentionally does NOT run // Canvas(dsl).reset() (eino runtime is still unimplemented in the Go // port); instead it stores a minimal but well-shaped row so that // subsequent ListAgentSessions / GetAgentSession / chat-completion // stubs can return a stable id and the integration suite can verify // the create + read + delete cycle without depending on a real LLM // run. When eino lands, the function will gain a pre-run prologue // pass that calls Canvas.Reset() and stores the assistant message. // // Required columns (per the API4Conversation entity, see // internal/entity/api_token.go:37-52): // - id : 32-hex uuid, matches Python uuid.uuid4().hex // - dialog_id : agent canvas id // - user_id : caller's id // - message : JSON array (default []); GET path normalises it // - reference : JSON object (default {}) so GET-side parsing // does not crash on .chunks // - dsl : JSON map; copied from user_canvas.dsl if the // caller did not pass one // - create_time : unix-millis // - update_time : unix-millis // - create_date : local-time.Truncate(time.Second) // - update_date : local-time.Truncate(time.Second) func (s *AgentService) CreateAgentSession(req *CreateAgentSessionRequest) (*entity.API4Conversation, common.ErrorCode, error) { if req == nil { return nil, common.CodeArgumentError, errors.New("create agent session: nil request") } if req.AgentID == "" { return nil, common.CodeArgumentError, errors.New("create agent session: agent_id is required") } if req.UserID == "" { return nil, common.CodeArgumentError, errors.New("create agent session: user_id is required") } ok, err := s.CheckCanvasAccess(req.UserID, req.AgentID) if err != nil { return nil, common.CodeServerError, fmt.Errorf("check canvas access: %w", err) } if !ok { return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.") } messages := req.Messages if len(messages) == 0 { messages = json.RawMessage(`[]`) } reference := json.RawMessage(`{}`) var dsl entity.JSONMap if len(req.DSL) > 0 { _ = json.Unmarshal(req.DSL, &dsl) } if len(dsl) == 0 { canvas, gErr := s.canvasDAO.GetByID(req.AgentID) if gErr != nil { if errors.Is(gErr, gorm.ErrRecordNotFound) { return nil, common.CodeOperatingError, errors.New("Agent not found or no permission.") } return nil, common.CodeServerError, fmt.Errorf("load canvas dsl: %w", gErr) } dsl = canvas.DSL } name := strings.TrimSpace(req.Name) if name == "" { name = "session" } var namePtr = &name var sourcePtr *string if req.Source != "" { sourcePtr = &req.Source } id := strings.ReplaceAll(uuid.New().String(), "-", "")[:32] // CreateTime / UpdateTime / CreateDate / UpdateDate are filled in // by entity.BaseModel.BeforeCreate when the DAO Create() call runs, // so we do not set them explicitly here. row := &entity.API4Conversation{ ID: id, Name: namePtr, DialogID: req.AgentID, UserID: req.UserID, Message: messages, Reference: reference, Source: sourcePtr, DSL: dsl, } if err := s.api4ConversationDAO.Create(row); err != nil { return nil, common.CodeServerError, fmt.Errorf("create agent session: %w", err) } return row, common.CodeSuccess, nil }