diff --git a/internal/dao/chat.go b/internal/dao/chat.go index 98d300a3f28..75817a28c33 100644 --- a/internal/dao/chat.go +++ b/internal/dao/chat.go @@ -238,3 +238,16 @@ func (dao *ChatDAO) QueryByTenantIDAndID(tenantID string, chatID string, status err := DB.Where("tenant_id = ? AND id = ? AND status = ?", tenantID, chatID, status).Find(&chats).Error return chats, err } + +// NameConflictExists returns true when a *different* chat in the same tenant already uses the given name. +// excludeID may be empty (create path) or the ID of the chat being updated (patch path). +func (dao *ChatDAO) NameConflictExists(tenantID, name, excludeID, status string) (bool, error) { + query := DB.Model(&entity.Chat{}). + Where("tenant_id = ? AND LOWER(name) = LOWER(?) AND status = ?", tenantID, name, status) + if excludeID != "" { + query = query.Where("id <> ?", excludeID) + } + var count int64 + err := query.Count(&count).Error + return count > 0, err +} diff --git a/internal/handler/chat.go b/internal/handler/chat.go index 186763cbcc2..4ca3a6171bc 100644 --- a/internal/handler/chat.go +++ b/internal/handler/chat.go @@ -17,12 +17,15 @@ package handler import ( + "errors" + "io" "net/http" - "ragflow/internal/common" "strconv" + "strings" "github.com/gin-gonic/gin" + "ragflow/internal/common" "ragflow/internal/service" ) @@ -259,19 +262,11 @@ func (h *ChatHandler) RemoveChats(c *gin.Context) { // Call service to remove dialogs if err := h.chatService.RemoveChats(userID, req.DialogIDs); err != nil { - // Check if it's an authorization error - if err.Error() == "only owner of chat authorized for this operation" { - c.JSON(http.StatusForbidden, gin.H{ - "code": 403, - "data": false, - "message": err.Error(), - }) + if errors.Is(err, service.ErrChatNoAuth) { + c.JSON(http.StatusOK, gin.H{"code": common.CodeAuthenticationError, "data": false, "message": err.Error()}) return } - c.JSON(http.StatusInternalServerError, gin.H{ - "code": 500, - "message": err.Error(), - }) + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": err.Error()}) return } @@ -374,3 +369,174 @@ func (h *ChatHandler) GetChat(c *gin.Context) { "message": "success", }) } + +// CreateChat creates a new chat dialog. +// @Summary Create Chat +// @Description Create a new chat dialog for the current user. +// @Tags chat +// @Accept json +// @Produce json +// @Param request body service.CreateChatRequest true "chat configuration" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/chats [post] +func (h *ChatHandler) CreateChat(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + var req service.CreateChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": err.Error()}) + return + } + + data, err := h.chatService.CreateChat(user.ID, &req) + if err != nil { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": data, "message": "success"}) +} + +// PatchChat partially updates an existing chat dialog (owner only). +// @Summary Patch Chat +// @Description Partially update a chat dialog. Prompt config and LLM settings are merged with existing values. +// @Tags chat +// @Accept json +// @Produce json +// @Param chat_id path string true "Chat ID" +// @Param request body service.PatchChatRequest true "fields to update" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/chats/{chat_id} [patch] +func (h *ChatHandler) PatchChat(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + chatID := c.Param("chat_id") + if chatID == "" { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": nil, "message": "chat_id is required"}) + return + } + + // Authorize before reading the body (parity with Python, which calls + // _ensure_owned_chat first) so malformed input cannot bypass the ownership + // check or leak a raw Go unmarshal error. + if err := h.chatService.EnsureOwnedChat(user.ID, chatID); err != nil { + c.JSON(http.StatusOK, gin.H{"code": common.CodeAuthenticationError, "data": false, "message": err.Error()}) + return + } + + var req service.PatchChatRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": "Invalid request body."}) + return + } + + data, err := h.chatService.PatchChat(user.ID, chatID, &req) + if err != nil { + if errors.Is(err, service.ErrChatNoAuth) { + c.JSON(http.StatusOK, gin.H{"code": common.CodeAuthenticationError, "data": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": data, "message": "success"}) +} + +// DeleteChatByID soft-deletes a single chat (status → "0"). Owner only. +// @Summary Delete Chat +// @Description Soft-delete a chat dialog by ID. +// @Tags chat +// @Produce json +// @Param chat_id path string true "Chat ID" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/chats/{chat_id} [delete] +func (h *ChatHandler) DeleteChatByID(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + chatID := c.Param("chat_id") + if chatID == "" { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": nil, "message": "chat_id is required"}) + return + } + + if err := h.chatService.DeleteChatByID(user.ID, chatID); err != nil { + if errors.Is(err, service.ErrChatNoAuth) { + c.JSON(http.StatusOK, gin.H{"code": common.CodeAuthenticationError, "data": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": true, "message": "success"}) +} + +// CreateChatSession creates a new conversation session for a chat. Owner only. +// @Summary Create Chat Session +// @Description Create a new conversation session for the given chat. +// @Tags chat +// @Accept json +// @Produce json +// @Param chat_id path string true "Chat ID" +// @Param request body object false "session name" +// @Success 200 {object} map[string]interface{} +// @Router /api/v1/chats/{chat_id}/sessions [post] +func (h *ChatHandler) CreateChatSession(c *gin.Context) { + user, errorCode, errorMessage := GetUser(c) + if errorCode != common.CodeSuccess { + jsonError(c, errorCode, errorMessage) + return + } + + chatID := c.Param("chat_id") + if chatID == "" { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": nil, "message": "chat_id is required"}) + return + } + + var body struct { + Name *string `json:"name"` + } + // The body is optional (absent body → default name), but a non-empty malformed + // body should fail fast with a sanitized message. + if err := c.ShouldBindJSON(&body); err != nil && !errors.Is(err, io.EOF) { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": "Invalid request body."}) + return + } + + var name string + if body.Name == nil { + name = "New session" + } else { + name = strings.TrimSpace(*body.Name) + if name == "" { + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": "`name` can not be empty."}) + return + } + } + + data, err := h.chatService.CreateChatSession(user.ID, chatID, name) + if err != nil { + if errors.Is(err, service.ErrChatNoAuth) { + c.JSON(http.StatusOK, gin.H{"code": common.CodeAuthenticationError, "data": false, "message": err.Error()}) + return + } + c.JSON(http.StatusOK, gin.H{"code": common.CodeDataError, "data": false, "message": err.Error()}) + return + } + + c.JSON(http.StatusOK, gin.H{"code": common.CodeSuccess, "data": data, "message": "success"}) +} diff --git a/internal/handler/chat_session.go b/internal/handler/chat_session.go index c3489d70e51..72b32cfcd6f 100644 --- a/internal/handler/chat_session.go +++ b/internal/handler/chat_session.go @@ -17,13 +17,14 @@ package handler import ( + "errors" "fmt" "io" "net/http" - "ragflow/internal/common" "github.com/gin-gonic/gin" + "ragflow/internal/common" "ragflow/internal/service" ) @@ -172,10 +173,11 @@ func (h *ChatSessionHandler) ListChatSessions(c *gin.Context) { // Call service to list chat sessions result, err := h.chatSessionService.ListChatSessions(userID, chatID) if err != nil { - // Check if it's an authorization error - if err.Error() == "Only owner of dialog authorized for this operation" { - c.JSON(http.StatusForbidden, gin.H{ - "code": 403, + // Parity with Python _ensure_owned_chat: a non-owned or invalid chat is an + // authorization failure (code 109, HTTP 200) — never a 500. + if errors.Is(err, service.ErrChatNoAuth) { + c.JSON(http.StatusOK, gin.H{ + "code": common.CodeAuthenticationError, "data": false, "message": err.Error(), }) diff --git a/internal/router/router.go b/internal/router/router.go index 9910b540d2f..e92d2529093 100644 --- a/internal/router/router.go +++ b/internal/router/router.go @@ -224,8 +224,13 @@ func (r *Router) Setup(engine *gin.Engine) { chats := v1.Group("/chats") { chats.GET("", r.chatHandler.ListChats) + chats.POST("", r.chatHandler.CreateChat) chats.GET("/:chat_id", r.chatHandler.GetChat) + chats.PUT("/:chat_id", r.chatHandler.PatchChat) + chats.PATCH("/:chat_id", r.chatHandler.PatchChat) + chats.DELETE("/:chat_id", r.chatHandler.DeleteChatByID) chats.GET("/:chat_id/sessions", r.chatSessionHandler.ListChatSessions) + chats.POST("/:chat_id/sessions", r.chatHandler.CreateChatSession) } // Searchbot routes diff --git a/internal/service/chat.go b/internal/service/chat.go index 122b0c227b9..52e1953c3eb 100644 --- a/internal/service/chat.go +++ b/internal/service/chat.go @@ -17,32 +17,79 @@ package service import ( + "encoding/json" "errors" "fmt" - "ragflow/internal/common" - "ragflow/internal/entity" + "os" "strings" "unicode/utf8" + "ragflow/internal/common" "ragflow/internal/dao" + "ragflow/internal/entity" ) // ChatService chat service type ChatService struct { - chatDAO *dao.ChatDAO - kbDAO *dao.KnowledgebaseDAO - userTenantDAO *dao.UserTenantDAO - tenantDAO *dao.TenantDAO + chatDAO *dao.ChatDAO + chatSessionDAO *dao.ChatSessionDAO + kbDAO *dao.KnowledgebaseDAO + userTenantDAO *dao.UserTenantDAO + tenantDAO *dao.TenantDAO + tenantLLMDAO *dao.TenantLLMDAO } // NewChatService create chat service func NewChatService() *ChatService { return &ChatService{ - chatDAO: dao.NewChatDAO(), - kbDAO: dao.NewKnowledgebaseDAO(), - userTenantDAO: dao.NewUserTenantDAO(), - tenantDAO: dao.NewTenantDAO(), + chatDAO: dao.NewChatDAO(), + chatSessionDAO: dao.NewChatSessionDAO(), + kbDAO: dao.NewKnowledgebaseDAO(), + userTenantDAO: dao.NewUserTenantDAO(), + tenantDAO: dao.NewTenantDAO(), + tenantLLMDAO: dao.NewTenantLLMDAO(), + } +} + +// defaultRerankModels mirrors Python's _DEFAULT_RERANK_MODELS — built-in rerank +// models that are always available and do not need a tenant_llm record. +var defaultRerankModels = map[string]struct{}{ + "BAAI/bge-reranker-v2-m3": {}, + "maidalun1020/bce-reranker-base_v1": {}, +} + +// validateLLMID checks that the given llm_id exists in the tenant's LLM table. +// Mirrors Python _validate_llm_id (chat model type). +func (s *ChatService) validateLLMID(userID, llmID string) error { + if llmID == "" { + return nil + } + _, _, err := dao.LookupTenantLLMByName(s.tenantLLMDAO, userID, llmID, entity.ModelTypeChat) + if err != nil { + return fmt.Errorf("`llm_id` %s doesn't exist", llmID) + } + return nil +} + +// validateRerankID checks that the given rerank_id exists in the tenant's LLM +// table (or is a built-in model that needs no record). +// Mirrors Python _validate_rerank_id. +func (s *ChatService) validateRerankID(userID, rerankID string) error { + if rerankID == "" { + return nil + } + modelName := rerankID + if idx := strings.Index(rerankID, "@"); idx > 0 { + modelName = rerankID[:idx] + } + if _, ok := defaultRerankModels[modelName]; ok { + return nil + } + _, _, err := dao.LookupTenantLLMByName(s.tenantLLMDAO, userID, rerankID, entity.ModelTypeRerank) + if err != nil { + return fmt.Errorf("`rerank_id` %s doesn't exist", rerankID) } + return nil } // ChatWithKBNames chat with knowledge base names @@ -55,6 +102,7 @@ type ChatWithKBNames struct { // ListChatsResponse list chats response type ListChatsResponse struct { Chats []*ChatWithKBNames `json:"chats"` + Total int64 `json:"total"` } // ListChats list chats for a user @@ -108,6 +156,7 @@ func (s *ChatService) ListChats(userID, status, keywords string, page, pageSize return &ListChatsResponse{ Chats: chatsWithKBNames, + Total: total, }, nil } @@ -567,49 +616,22 @@ func getEmbdIDs(kbs []*entity.Knowledgebase) []string { return ids } -// RemoveChats removes dialogs by setting their status to invalid (soft delete) -// Only the owner of the chat can perform this operation +// RemoveChats removes dialogs by setting their status to invalid (soft delete). +// Mirrors Python bulk_delete_chats: uses _ensure_owned_chat per entry. func (s *ChatService) RemoveChats(userID string, chatIDs []string) error { - // Get user's tenants - tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) - if err != nil { - return err - } - - // Build a set of user's tenant IDs for quick lookup - tenantIDSet := make(map[string]bool) - for _, tid := range tenantIDs { - tenantIDSet[tid] = true - } - // Also add userID itself as a tenant (for cases where tenant_id = user_id) - tenantIDSet[userID] = true - - // Check each chat and build update list var updates []map[string]interface{} for _, chatID := range chatIDs { - // Get the chat to check ownership - chat, err := s.chatDAO.GetByID(chatID) - if err != nil { - return fmt.Errorf("chat not found: %s", chatID) - } - - // Check if user is the owner (chat's tenant_id must be in user's tenants) - if !tenantIDSet[chat.TenantID] { - return errors.New("only owner of chat authorized for this operation") + if _, err := s.ensureOwnedChat(userID, chatID); err != nil { + return err } - - // Add to update list (soft delete by setting status to "0") updates = append(updates, map[string]interface{}{ "id": chatID, "status": "0", }) } - - // Batch update all dialogs if err := s.chatDAO.UpdateManyByID(updates); err != nil { return err } - return nil } @@ -682,3 +704,558 @@ func (s *ChatService) GetChat(userID string, chatID string) (*GetChatResponse, e KBNames: kbNames, }, nil } + +// ── helpers ──────────────────────────────────────────────────────────────────── + +// buildChatResponseMap produces the dict that matches Python _build_chat_response: +// all chat columns, kb_ids replaced by dataset_ids, plus kb_names. +func (s *ChatService) buildChatResponseMap(chat *entity.Chat) map[string]interface{} { + kbNames, datasetIDs := s.getDatasetNamesAndIDs(chat.KBIDs) + return map[string]interface{}{ + "id": chat.ID, + "tenant_id": chat.TenantID, + "name": chat.Name, + "description": chat.Description, + "icon": chat.Icon, + "language": chat.Language, + "llm_id": chat.LLMID, + "llm_setting": chat.LLMSetting, + "prompt_type": chat.PromptType, + "prompt_config": chat.PromptConfig, + "meta_data_filter": chat.MetaDataFilter, + "similarity_threshold": chat.SimilarityThreshold, + "vector_similarity_weight": chat.VectorSimilarityWeight, + "top_n": chat.TopN, + "top_k": chat.TopK, + "do_refer": chat.DoRefer, + "rerank_id": chat.RerankID, + "dataset_ids": datasetIDs, + "kb_names": kbNames, + "status": chat.Status, + "create_time": chat.CreateTime, + "create_date": chat.CreateDate, + "update_time": chat.UpdateTime, + "update_date": chat.UpdateDate, + "tenant_llm_id": chat.TenantLLMID, + "tenant_rerank_id": chat.TenantRerankID, + } +} + +// validateName mirrors Python _validate_name. +// required=true is used for POST (name must be provided and non-empty); +// required=false is used for PATCH (name may be absent, but not empty string). +func validateName(name *string, required bool) (string, error) { + if name == nil { + if required { + return "", fmt.Errorf("`name` is required.") + } + return "", nil + } + trimmed := strings.TrimSpace(*name) + if trimmed == "" { + if required { + return "", fmt.Errorf("`name` is required.") + } + return "", fmt.Errorf("`name` cannot be empty.") + } + if len([]byte(trimmed)) > 255 { + return "", fmt.Errorf("Chat name length is %d which is larger than 255.", len([]byte(trimmed))) + } + return trimmed, nil +} + +// validateDatasetIDs validates that each dataset ID exists, is accessible to +// the user, has parsed files, and all use the same embedding model. +// Returns the resolved (valid) IDs or an error. +func (s *ChatService) validateDatasetIDs(datasetIDs []string, userID string) ([]string, error) { + if len(datasetIDs) == 0 { + return []string{}, nil + } + + userTenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(userID) + if err != nil { + return nil, fmt.Errorf("failed to resolve user tenants: %w", err) + } + authorised := make(map[string]struct{}, len(userTenantIDs)+1) + for _, tid := range userTenantIDs { + authorised[tid] = struct{}{} + } + authorised[userID] = struct{}{} + + var validIDs []string + var embdID string + + for _, id := range datasetIDs { + if id == "" { + continue + } + kb, err := s.kbDAO.GetByID(id) + if err != nil || kb == nil { + return nil, fmt.Errorf("You don't own the dataset %s", id) + } + if _, ok := authorised[kb.TenantID]; !ok { + return nil, fmt.Errorf("You don't own the dataset %s", id) + } + if kb.ChunkNum == 0 { + return nil, fmt.Errorf("The dataset %s doesn't own parsed file", id) + } + // Check embedding model consistency. + base := embdModelBase(kb.EmbdID) + if embdID == "" { + embdID = base + } else if embdID != base { + return nil, fmt.Errorf("Datasets use different embedding models") + } + validIDs = append(validIDs, id) + } + return validIDs, nil +} + +// embdModelBase strips the @vendor suffix so model names can be compared. +func embdModelBase(embdID string) string { + if idx := strings.LastIndex(embdID, "@"); idx > 0 { + return embdID[:idx] + } + return embdID +} + +// ensureOwnedChat checks that userID is the owner of a valid chat. +// Mirrors Python _ensure_owned_chat. +// ErrChatNoAuth mirrors Python _ensure_owned_chat, which queries by +// (tenant_id, id, status=VALID) and collapses every failure mode — chat missing, +// not active, or owned by another tenant — into a single "No authorization." +// (code 109). Handlers detect it with errors.Is to return the right code instead +// of leaking a 500 or a misleading "Chat not found!" (code 102). +var ErrChatNoAuth = errors.New("No authorization.") + +func (s *ChatService) ensureOwnedChat(userID, chatID string) (*entity.Chat, error) { + chat, err := s.chatDAO.GetByID(chatID) + if err != nil { + return nil, ErrChatNoAuth + } + if chat.Status == nil || *chat.Status != "1" { + return nil, ErrChatNoAuth + } + if chat.TenantID != userID { + return nil, ErrChatNoAuth + } + return chat, nil +} + +// EnsureOwnedChat verifies the user owns the active chat, returning ErrChatNoAuth +// otherwise. Exposed so handlers can authorize before parsing a request body +// (parity with Python, which calls _ensure_owned_chat before reading the payload). +func (s *ChatService) EnsureOwnedChat(userID, chatID string) error { + _, err := s.ensureOwnedChat(userID, chatID) + return err +} + +// ── CreateChat ───────────────────────────────────────────────────────────────── + +// CreateChatRequest mirrors Python POST /chats body. +type CreateChatRequest struct { + Name *string `json:"name"` + Description string `json:"description"` + Icon string `json:"icon"` + DatasetIDs []string `json:"dataset_ids"` + LLMID string `json:"llm_id"` + LLMSetting map[string]interface{} `json:"llm_setting"` + RerankID string `json:"rerank_id"` + PromptConfig map[string]interface{} `json:"prompt_config"` + SimilarityThreshold *float64 `json:"similarity_threshold"` + VectorSimilarityWeight *float64 `json:"vector_similarity_weight"` + TopN *int64 `json:"top_n"` + TopK *int64 `json:"top_k"` + TenantID *string `json:"tenant_id"` +} + +// CreateChat creates a new chat dialog, mirroring Python POST /api/v1/chats. +func (s *ChatService) CreateChat(userID string, req *CreateChatRequest) (map[string]interface{}, error) { + if req.TenantID != nil { + return nil, fmt.Errorf("`tenant_id` must not be provided.") + } + + name, err := validateName(req.Name, true) + if err != nil { + return nil, err + } + + kbIDs, err := s.validateDatasetIDs(req.DatasetIDs, userID) + if err != nil { + return nil, err + } + + // Resolve tenant / default LLM. + tenant, tenantErr := s.tenantDAO.GetByID(userID) + if tenantErr != nil { + return nil, fmt.Errorf("Tenant not found!") + } + llmID := req.LLMID + if llmID == "" && tenant != nil { + llmID = tenant.LLMID + } + + if err := s.validateLLMID(userID, req.LLMID); err != nil { + return nil, err + } + if err := s.validateRerankID(userID, req.RerankID); err != nil { + return nil, err + } + + // Apply prompt defaults. + promptConfig := applyPromptDefaults(req.PromptConfig, kbIDs) + + // Duplicate name check. + exists, err := s.chatDAO.NameConflictExists(userID, name, "", "1") + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("Duplicated chat name in creating chat.") + } + + description := req.Description + if description == "" { + description = "A helpful Assistant" + } + topN := int64(6) + if req.TopN != nil { + topN = *req.TopN + } + topK := int64(1024) + if req.TopK != nil { + topK = *req.TopK + } + simThreshold := 0.1 + if req.SimilarityThreshold != nil { + simThreshold = *req.SimilarityThreshold + } + vecWeight := 0.3 + if req.VectorSimilarityWeight != nil { + vecWeight = *req.VectorSimilarityWeight + } + llmSetting := req.LLMSetting + if llmSetting == nil { + llmSetting = map[string]interface{}{} + } + kbIDsJSON := make(entity.JSONSlice, len(kbIDs)) + for i, id := range kbIDs { + kbIDsJSON[i] = id + } + // Mirror Python's Dialog.language default, which is locale-driven: + // "Chinese" when the server LANG is zh_CN, otherwise "English". + lang := "English" + if strings.Contains(os.Getenv("LANG"), "zh_CN") { + lang = "Chinese" + } + status := "1" + chat := &entity.Chat{ + ID: common.GenerateUUID(), + TenantID: userID, + Name: &name, + Description: &description, + Icon: &req.Icon, + Language: &lang, + LLMID: llmID, + LLMSetting: llmSetting, + PromptConfig: promptConfig, + TopN: topN, + TopK: topK, + RerankID: req.RerankID, + SimilarityThreshold: simThreshold, + VectorSimilarityWeight: vecWeight, + KBIDs: kbIDsJSON, + Status: &status, + } + if err := s.chatDAO.Create(chat); err != nil { + return nil, fmt.Errorf("Failed to create chat.") + } + created, err := s.chatDAO.GetByID(chat.ID) + if err != nil { + return nil, fmt.Errorf("Failed to retrieve created chat.") + } + return s.buildChatResponseMap(created), nil +} + +// ── PatchChat ────────────────────────────────────────────────────────────────── + +// PatchChatRequest mirrors Python PATCH /chats/ body (all fields optional). +type PatchChatRequest struct { + Name *string `json:"name"` + Description *string `json:"description"` + Icon *string `json:"icon"` + DatasetIDs []string `json:"dataset_ids"` + LLMID *string `json:"llm_id"` + LLMSetting map[string]interface{} `json:"llm_setting"` + RerankID *string `json:"rerank_id"` + PromptConfig map[string]interface{} `json:"prompt_config"` + SimilarityThreshold *float64 `json:"similarity_threshold"` + VectorSimilarityWeight *float64 `json:"vector_similarity_weight"` + TopN *int64 `json:"top_n"` + TopK *int64 `json:"top_k"` +} + +// PatchChat partially updates a chat, mirroring Python PATCH /api/v1/chats/. +func (s *ChatService) PatchChat(userID, chatID string, req *PatchChatRequest) (map[string]interface{}, error) { + current, err := s.ensureOwnedChat(userID, chatID) + if err != nil { + return nil, err + } + + updates := map[string]interface{}{} + + if req.Name != nil { + name, err := validateName(req.Name, false) + if err != nil { + return nil, err + } + if name != "" && !strings.EqualFold(name, derefStr(current.Name)) { + exists, err := s.chatDAO.NameConflictExists(userID, name, chatID, "1") + if err != nil { + return nil, err + } + if exists { + return nil, fmt.Errorf("Duplicated chat name.") + } + updates["name"] = name + } + } + + if req.Description != nil { + updates["description"] = *req.Description + } + if req.Icon != nil { + updates["icon"] = *req.Icon + } + + if req.DatasetIDs != nil { + kbIDs, err := s.validateDatasetIDs(req.DatasetIDs, userID) + if err != nil { + return nil, err + } + kbIDsJSON := make(entity.JSONSlice, len(kbIDs)) + for i, id := range kbIDs { + kbIDsJSON[i] = id + } + updates["kb_ids"] = kbIDsJSON + } + + if req.LLMID != nil { + if err := s.validateLLMID(userID, *req.LLMID); err != nil { + return nil, err + } + updates["llm_id"] = *req.LLMID + } + + // Merge llm_setting with existing. + if req.LLMSetting != nil { + existing := map[string]interface{}{} + for k, v := range current.LLMSetting { + existing[k] = v + } + for k, v := range req.LLMSetting { + existing[k] = v + } + updates["llm_setting"] = existing + } + + // Merge prompt_config with existing (PATCH semantics). + if req.PromptConfig != nil { + existing := map[string]interface{}{} + for k, v := range current.PromptConfig { + existing[k] = v + } + for k, v := range req.PromptConfig { + existing[k] = v + } + updates["prompt_config"] = existing + } + + if req.RerankID != nil { + if err := s.validateRerankID(userID, *req.RerankID); err != nil { + return nil, err + } + updates["rerank_id"] = *req.RerankID + } + if req.SimilarityThreshold != nil { + updates["similarity_threshold"] = *req.SimilarityThreshold + } + if req.VectorSimilarityWeight != nil { + updates["vector_similarity_weight"] = *req.VectorSimilarityWeight + } + if req.TopN != nil { + updates["top_n"] = *req.TopN + } + if req.TopK != nil { + updates["top_k"] = *req.TopK + } + + if len(updates) > 0 { + if err := s.chatDAO.UpdateByID(chatID, updates); err != nil { + return nil, fmt.Errorf("Failed to update chat.") + } + } + + updated, err := s.chatDAO.GetByID(chatID) + if err != nil { + return nil, fmt.Errorf("Failed to retrieve updated chat.") + } + return s.buildChatResponseMap(updated), nil +} + +// ── DeleteChatByID ───────────────────────────────────────────────────────────── + +// DeleteChatByID soft-deletes a single chat (status → "0"), mirroring Python DELETE /chats/. +func (s *ChatService) DeleteChatByID(userID, chatID string) error { + if _, err := s.ensureOwnedChat(userID, chatID); err != nil { + return err + } + if err := s.chatDAO.UpdateByID(chatID, map[string]interface{}{"status": "0"}); err != nil { + return fmt.Errorf("Failed to delete chat %s", chatID) + } + return nil +} + +// ── CreateChatSession ────────────────────────────────────────────────────────── + +// CreateChatSession creates a new conversation for a chat, mirroring Python POST /chats//sessions. +func (s *ChatService) CreateChatSession(userID, chatID, name string) (map[string]interface{}, error) { + chat, err := s.ensureOwnedChat(userID, chatID) + if err != nil { + return nil, err + } + + name = strings.TrimSpace(name) + if name == "" { + name = "New session" + } + if len([]rune(name)) > 255 { + name = string([]rune(name)[:255]) + } + + prologue := "" + if p, ok := chat.PromptConfig["prologue"]; ok { + if ps, ok := p.(string); ok { + prologue = ps + } + } + + initMsg, _ := json.Marshal([]map[string]interface{}{ + {"role": "assistant", "content": prologue}, + }) + refJSON, _ := json.Marshal([]interface{}{}) + + session := &entity.ChatSession{ + ID: common.GenerateUUID(), + DialogID: chatID, + Name: &name, + Message: initMsg, + Reference: refJSON, + UserID: &userID, + } + if err := s.chatSessionDAO.Create(session); err != nil { + return nil, fmt.Errorf("Fail to create a session!") + } + created, err := s.chatSessionDAO.GetByID(session.ID) + if err != nil { + return nil, fmt.Errorf("Fail to create a session!") + } + return buildSessionResponseMap(created), nil +} + +// buildSessionResponseMap mirrors Python _build_session_response: +// renames dialog_id → chat_id and message → messages. +func buildSessionResponseMap(s *entity.ChatSession) map[string]interface{} { + var messages interface{} + if len(s.Message) > 0 { + _ = json.Unmarshal(s.Message, &messages) + } + if messages == nil { + messages = []interface{}{} + } + var reference interface{} + if len(s.Reference) > 0 { + _ = json.Unmarshal(s.Reference, &reference) + } + if reference == nil { + reference = []interface{}{} + } + return map[string]interface{}{ + "id": s.ID, + "chat_id": s.DialogID, + "name": s.Name, + "messages": messages, + "reference": reference, + "user_id": s.UserID, + "create_time": s.CreateTime, + "create_date": s.CreateDate, + "update_time": s.UpdateTime, + "update_date": s.UpdateDate, + } +} + +// ── internal helpers ─────────────────────────────────────────────────────────── + +// applyPromptDefaults mirrors Python _apply_prompt_defaults. +func applyPromptDefaults(pc map[string]interface{}, kbIDs []string) entity.JSONMap { + defaults := map[string]interface{}{ + "system": ("You are an intelligent assistant. Please summarize the content of the dataset to answer the question. " + + "Please list the data in the dataset and answer in detail. When all dataset content is irrelevant to the " + + "question, your answer must include the sentence \"The answer you are looking for is not found in the dataset!\" " + + "Answers need to consider chat history.\n Here is the knowledge base:\n {knowledge}\n The above is the knowledge base."), + "prologue": "Hi! I'm your assistant. What can I do for you?", + "parameters": []interface{}{map[string]interface{}{"key": "knowledge", "optional": false}}, + "empty_response": "Sorry! No relevant content was found in the knowledge base!", + "quote": true, + "tts": false, + "refine_multiturn": true, + } + result := entity.JSONMap{} + for k, v := range defaults { + result[k] = v + } + if pc != nil { + for k, v := range pc { + result[k] = v + } + } + // If no datasets and parameters reference {knowledge}, keep the defaults as-is. + // If datasets provided but parameters missing, add the knowledge parameter. + if len(kbIDs) > 0 { + if params, ok := result["parameters"]; !ok || params == nil { + sys, _ := result["system"].(string) + if strings.Contains(sys, "{knowledge}") { + result["parameters"] = []interface{}{map[string]interface{}{"key": "knowledge", "optional": false}} + } + } + } + return result +} + +// validatePromptParams checks that every non-optional parameter has a placeholder in the system prompt. +func validatePromptParams(pc entity.JSONMap) error { + sys, _ := pc["system"].(string) + params, _ := pc["parameters"].([]interface{}) + for _, p := range params { + pm, ok := p.(map[string]interface{}) + if !ok { + continue + } + optional, _ := pm["optional"].(bool) + if optional { + continue + } + key, _ := pm["key"].(string) + if key != "" && !strings.Contains(sys, fmt.Sprintf("{%s}", key)) { + return fmt.Errorf("Parameter '%s' is not used", key) + } + } + return nil +} + +func derefStr(s *string) string { + if s == nil { + return "" + } + return *s +} diff --git a/internal/service/chat_session.go b/internal/service/chat_session.go index fe4de1e5f91..8fcfd4cf2f9 100644 --- a/internal/service/chat_session.go +++ b/internal/service/chat_session.go @@ -203,7 +203,7 @@ type ListChatSessionsRequest struct { // ListChatSessionsResponse list chat sessions response type ListChatSessionsResponse struct { - Sessions []*entity.ChatSession + Sessions []map[string]interface{} } // ListChatSessions lists chat sessions for a dialog @@ -239,7 +239,9 @@ func (s *ChatSessionService) ListChatSessions(userID string, chatID string) (*Li } if !isOwner { - return nil, errors.New("only owner of dialog authorized for this operation") + // Parity with Python _ensure_owned_chat: a non-owned/invalid chat is an + // authorization failure (code 109), not a 500. + return nil, ErrChatNoAuth } // List chat sessions @@ -248,7 +250,15 @@ func (s *ChatSessionService) ListChatSessions(userID string, chatID string) (*Li return nil, err } - return &ListChatSessionsResponse{Sessions: sessions}, nil + // Map each session through buildSessionResponseMap so the response matches + // Python _build_session_response (chat_id instead of dialog_id, messages as a + // JSON array instead of raw DB bytes). + mapped := make([]map[string]interface{}, 0, len(sessions)) + for _, sess := range sessions { + mapped = append(mapped, buildSessionResponseMap(sess)) + } + + return &ListChatSessionsResponse{Sessions: mapped}, nil } // Completion performs chat completion with full RAG support