2026-03-04 19:17:16 +08:00
//
// 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 (
2026-06-09 20:07:45 -07:00
"context"
2026-03-04 19:17:16 +08:00
"encoding/json"
"errors"
"fmt"
2026-07-01 15:52:52 +08:00
"math"
2026-05-06 10:41:58 +08:00
"ragflow/internal/common"
2026-07-02 10:33:27 +08:00
"ragflow/internal/engine"
feat[Go]: implement Create-Chat/Session, Delete-Session (#16386)
### What problem does this PR solve?
As title:
implement:
```go
chats.POST("", r.chatHandler.Create)
chats.POST("/:chat_id/sessions", r.chatSessionHandler.CreateSession)
chats.DELETE("/:chat_id/sessions", r.chatSessionHandler.DeleteSessions)
```
bug fixed:
https://github.com/infiniflow/ragflow/blob/f80d4c784368853ec727231b6891860629973519/internal/handler/chat.go#L84
↓
```go
result, err := h.chatService.ListChats(userID, "1", keywords, page, pageSize, orderby, desc)
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-06-26 19:23:45 +08:00
"ragflow/internal/storage"
2026-07-07 17:22:08 +08:00
"ragflow/internal/utility"
2026-07-02 10:33:27 +08:00
"strconv"
2026-03-04 19:17:16 +08:00
"strings"
"time"
feat[Go]: implement Create-Chat/Session, Delete-Session (#16386)
### What problem does this PR solve?
As title:
implement:
```go
chats.POST("", r.chatHandler.Create)
chats.POST("/:chat_id/sessions", r.chatSessionHandler.CreateSession)
chats.DELETE("/:chat_id/sessions", r.chatSessionHandler.DeleteSessions)
```
bug fixed:
https://github.com/infiniflow/ragflow/blob/f80d4c784368853ec727231b6891860629973519/internal/handler/chat.go#L84
↓
```go
result, err := h.chatService.ListChats(userID, "1", keywords, page, pageSize, orderby, desc)
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-06-26 19:23:45 +08:00
"go.uber.org/zap"
2026-06-24 17:34:01 +08:00
"gorm.io/gorm"
2026-03-04 19:17:16 +08:00
"ragflow/internal/dao"
2026-03-27 19:25:18 +08:00
"ragflow/internal/entity"
2026-03-04 19:17:16 +08:00
)
2026-06-18 18:07:27 +08:00
// Interfaces for testability — satisfied by the concrete DAO/pipeline types.
2026-06-09 20:07:45 -07:00
2026-06-18 18:07:27 +08:00
type chatSessionStore interface {
GetByID ( id string ) ( * entity . ChatSession , error )
2026-06-24 17:34:01 +08:00
GetBySessionIDAndChatID ( sessionID , chatID string ) ( * entity . ChatSession , error )
2026-06-18 18:07:27 +08:00
Create ( conv * entity . ChatSession ) error
UpdateByID ( id string , updates map [ string ] interface { } ) error
DeleteByID ( id string ) error
ListByChatID ( chatID string ) ( [ ] * entity . ChatSession , error )
GetDialogByID ( chatID string ) ( * entity . Chat , error )
CheckDialogExists ( tenantID , chatID string ) ( bool , error )
2026-06-09 20:07:45 -07:00
}
2026-06-18 18:07:27 +08:00
type userTenantStore interface {
GetTenantIDsByUserID ( userID string ) ( [ ] string , error )
2026-06-09 20:07:45 -07:00
}
2026-06-18 18:07:27 +08:00
type chatPipelineRunner interface {
2026-07-06 10:13:46 +08:00
AsyncChat ( ctx context . Context , userID string , chat * entity . Chat , messages [ ] map [ string ] interface { } , stream bool , kwargs map [ string ] interface { } ) ( <- chan AsyncChatResult , error )
2026-06-09 20:07:45 -07:00
}
2026-06-30 16:28:48 +08:00
// chunkFeedbackApplier is the dispatch seam for chunk-level feedback
// persistence. Mirrors the Python ChunkFeedbackService.apply_feedback
// (api/db/services/chunk_feedback_service.py) call site at
// api/apps/restful_apis/chat_api.py — that handler records the thumb
// vote against every chunk that produced the assistant message, in
2026-07-02 10:33:27 +08:00
// addition to the session-level thumbup field. Production uses
// *ChatSessionService itself via applyChunkFeedback; tests can swap
// in a fake by setting ChatSessionService.chunkFeedbackApplier.
2026-06-30 16:28:48 +08:00
type chunkFeedbackApplier interface {
2026-07-02 10:33:27 +08:00
applyChunkFeedback ( ctx context . Context , tenantID string , reference map [ string ] interface { } , isPositive bool ) ( map [ string ] interface { } , error )
}
type chunkPagerankAdjuster interface {
AdjustChunkPagerank ( ctx context . Context , indexName , chunkID , kbID string , delta , minWeight , maxWeight float64 ) error
2026-06-30 16:28:48 +08:00
}
2026-06-18 18:07:27 +08:00
// ChatSessionService chat session (conversation) service.
// The RAG pipeline is delegated to ChatPipelineService.
2026-03-04 19:17:16 +08:00
type ChatSessionService struct {
2026-06-30 16:28:48 +08:00
chatSessionDAO chatSessionStore
userTenantDAO userTenantStore
pipeline chatPipelineRunner
chunkFeedbackApplier chunkFeedbackApplier
2026-07-02 10:33:27 +08:00
docEngine engine . DocEngine
2026-03-04 19:17:16 +08:00
}
// NewChatSessionService create chat session service
func NewChatSessionService ( ) * ChatSessionService {
return & ChatSessionService {
2026-06-18 18:07:27 +08:00
chatSessionDAO : dao . NewChatSessionDAO ( ) ,
userTenantDAO : dao . NewUserTenantDAO ( ) ,
pipeline : NewChatPipelineService ( ) ,
2026-07-02 10:33:27 +08:00
docEngine : engine . Get ( ) ,
}
}
// SetChatSessionRequest set chat session request.
type SetChatSessionRequest struct {
SessionID string ` json:"conversation_id,omitempty" `
DialogID string ` json:"dialog_id,omitempty" `
Name string ` json:"name,omitempty" `
IsNew bool ` json:"is_new" `
}
// SetChatSessionResponse set chat session response.
type SetChatSessionResponse struct {
* entity . ChatSession
}
// SetChatSession creates or updates a chat session.
// Kept as a compatibility entrypoint for older chat-session callers.
func ( s * ChatSessionService ) SetChatSession ( userID string , req * SetChatSessionRequest ) ( * SetChatSessionResponse , error ) {
name := req . Name
if name == "" {
name = "New chat session"
}
if len ( name ) > 255 {
name = name [ : 255 ]
}
if ! req . IsNew {
updates := map [ string ] interface { } {
"name" : name ,
"user_id" : userID ,
}
if err := s . chatSessionDAO . UpdateByID ( req . SessionID , updates ) ; err != nil {
return nil , errors . New ( "Chat session not found" )
}
session , err := s . chatSessionDAO . GetByID ( req . SessionID )
if err != nil {
return nil , errors . New ( "Fail to update a chat session" )
}
return & SetChatSessionResponse { ChatSession : session } , nil
}
dialog , err := s . chatSessionDAO . GetDialogByID ( req . DialogID )
if err != nil {
return nil , errors . New ( "Dialog not found" )
}
prologue := "Hi! I'm your assistant. What can I do for you?"
if dialog . PromptConfig != nil {
if p , ok := dialog . PromptConfig [ "prologue" ] . ( string ) ; ok && p != "" {
prologue = p
}
}
messagesJSON , _ := json . Marshal ( [ ] map [ string ] interface { } {
{
"role" : "assistant" ,
"content" : prologue ,
} ,
} )
referenceJSON , _ := json . Marshal ( [ ] interface { } { } )
session := & entity . ChatSession {
2026-07-07 17:22:08 +08:00
ID : utility . GenerateUUID ( ) ,
2026-07-02 10:33:27 +08:00
DialogID : req . DialogID ,
Name : & name ,
Message : messagesJSON ,
UserID : & userID ,
Reference : referenceJSON ,
}
if err := s . chatSessionDAO . Create ( session ) ; err != nil {
return nil , errors . New ( "Fail to create a chat session" )
}
return & SetChatSessionResponse { ChatSession : session } , nil
}
// RemoveChatSessions removes chat sessions.
// Kept as a compatibility entrypoint for older chat-session callers.
func ( s * ChatSessionService ) RemoveChatSessions ( userID string , chatSessions [ ] string ) error {
tenantIDs , err := s . userTenantDAO . GetTenantIDsByUserID ( userID )
if err != nil {
return err
}
tenantIDSet := make ( map [ string ] bool )
for _ , tid := range tenantIDs {
tenantIDSet [ tid ] = true
}
tenantIDSet [ userID ] = true
for _ , convID := range chatSessions {
session , err := s . chatSessionDAO . GetByID ( convID )
if err != nil {
return fmt . Errorf ( "Chat session not found: %s" , convID )
}
isOwner := false
for tenantID := range tenantIDSet {
exists , err := s . chatSessionDAO . CheckDialogExists ( tenantID , session . DialogID )
if err != nil {
return err
}
if exists {
isOwner = true
break
}
}
if ! isOwner {
return errors . New ( "Only owner of chat session authorized for this operation" )
}
if err := s . chatSessionDAO . DeleteByID ( convID ) ; err != nil {
return err
}
2026-03-04 19:17:16 +08:00
}
2026-07-02 10:33:27 +08:00
return nil
}
// ListChatSessionsRequest list chat sessions request.
type ListChatSessionsRequest struct {
DialogID string ` json:"dialog_id" binding:"required" `
2026-03-04 19:17:16 +08:00
}
// ListChatSessionsResponse list chat sessions response
type ListChatSessionsResponse struct {
2026-03-27 19:25:18 +08:00
Sessions [ ] * entity . ChatSession
2026-03-04 19:17:16 +08:00
}
2026-06-24 17:34:01 +08:00
type ChatSessionPayload struct {
ID string ` json:"id" `
ChatID string ` json:"chat_id" `
Name * string ` json:"name,omitempty" `
Messages [ ] map [ string ] interface { } ` json:"messages" `
Reference [ ] interface { } ` json:"reference" `
UserID * string ` json:"user_id,omitempty" `
Avatar * string ` json:"avatar,omitempty" `
CreateDate * time . Time ` json:"create_date,omitempty" `
UpdateDate * time . Time ` json:"update_date,omitempty" `
CreateTime * int64 ` json:"create_time,omitempty" `
UpdateTime * int64 ` json:"update_time,omitempty" `
}
2026-03-04 19:17:16 +08:00
// ListChatSessions lists chat sessions for a dialog
2026-05-07 17:14:22 +08:00
func ( s * ChatSessionService ) ListChatSessions ( userID string , chatID string ) ( * ListChatSessionsResponse , error ) {
2026-03-04 19:17:16 +08:00
// Get user's tenants
tenantIDs , err := s . userTenantDAO . GetTenantIDsByUserID ( userID )
if err != nil {
return nil , err
}
// Check if user is the owner of the dialog
isOwner := false
for _ , tenantID := range tenantIDs {
2026-05-07 17:14:22 +08:00
var exists bool
exists , err = s . chatSessionDAO . CheckDialogExists ( tenantID , chatID )
2026-03-04 19:17:16 +08:00
if err != nil {
return nil , err
}
if exists {
isOwner = true
break
}
}
// Also check with userID as tenant
if ! isOwner {
2026-05-07 17:14:22 +08:00
var exists bool
exists , err = s . chatSessionDAO . CheckDialogExists ( userID , chatID )
2026-03-04 19:17:16 +08:00
if err != nil {
return nil , err
}
isOwner = exists
}
if ! isOwner {
2026-05-07 17:14:22 +08:00
return nil , errors . New ( "only owner of dialog authorized for this operation" )
2026-03-04 19:17:16 +08:00
}
// List chat sessions
2026-05-07 17:14:22 +08:00
sessions , err := s . chatSessionDAO . ListByChatID ( chatID )
2026-03-04 19:17:16 +08:00
if err != nil {
return nil , err
}
return & ListChatSessionsResponse { Sessions : sessions } , nil
}
2026-06-24 17:34:01 +08:00
// GetSession returns one chat session after ownership validation.
func ( s * ChatSessionService ) GetSession ( userID , chatID , sessionID string ) ( * ChatSessionPayload , common . ErrorCode , error ) {
ok , err := s . ensureOwnedChat ( userID , chatID )
if err != nil {
return nil , common . CodeServerError , err
}
if ! ok {
return nil , common . CodeAuthenticationError , errors . New ( "No authorization." )
}
session , err := s . chatSessionDAO . GetByID ( sessionID )
if err != nil {
if isChatSessionNotFound ( err ) {
return nil , common . CodeDataError , errors . New ( "Session not found!" )
}
return nil , common . CodeServerError , err
}
if session . DialogID != chatID {
return nil , common . CodeDataError , errors . New ( "Session does not belong to this chat!" )
}
dialog , err := s . chatSessionDAO . GetDialogByID ( chatID )
if err != nil && ! isChatSessionNotFound ( err ) {
return nil , common . CodeServerError , err
}
return s . buildSessionPayload ( session , dialog , true ) , common . CodeSuccess , nil
}
feat[Go]: implement Create-Chat/Session, Delete-Session (#16386)
### What problem does this PR solve?
As title:
implement:
```go
chats.POST("", r.chatHandler.Create)
chats.POST("/:chat_id/sessions", r.chatSessionHandler.CreateSession)
chats.DELETE("/:chat_id/sessions", r.chatSessionHandler.DeleteSessions)
```
bug fixed:
https://github.com/infiniflow/ragflow/blob/f80d4c784368853ec727231b6891860629973519/internal/handler/chat.go#L84
↓
```go
result, err := h.chatService.ListChats(userID, "1", keywords, page, pageSize, orderby, desc)
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-06-26 19:23:45 +08:00
// CreateSession create a session in a dialog
func ( s * ChatSessionService ) CreateSession ( userID , chatID string , req map [ string ] interface { } ) ( * ChatSessionPayload , common . ErrorCode , error ) {
ok , err := s . ensureOwnedChat ( userID , chatID )
if err != nil {
return nil , common . CodeServerError , err
}
if ! ok {
return nil , common . CodeAuthenticationError , errors . New ( "No authorization." )
}
dialog , err := s . chatSessionDAO . GetDialogByID ( chatID )
if err != nil {
if isChatSessionNotFound ( err ) {
return nil , common . CodeDataError , errors . New ( "Chat not found!" )
}
return nil , common . CodeServerError , err
}
name := "New session"
if rawName , exists := req [ "name" ] ; exists {
nameStr , ok := rawName . ( string )
if ! ok || strings . TrimSpace ( nameStr ) == "" {
return nil , common . CodeDataError , errors . New ( "`name` can not be empty." )
}
name = strings . TrimSpace ( nameStr )
}
nameRunes := [ ] rune ( name )
if len ( nameRunes ) > 255 {
name = string ( nameRunes [ : 255 ] )
}
prologue := ""
if dialog . PromptConfig != nil {
if value , ok := dialog . PromptConfig [ "prologue" ] . ( string ) ; ok {
prologue = value
}
}
messagesJSON , _ := json . Marshal ( [ ] map [ string ] interface { } {
{
"role" : "assistant" ,
"content" : prologue ,
} ,
} )
referenceJSON , _ := json . Marshal ( [ ] interface { } { } )
conv := & entity . ChatSession {
2026-07-07 17:22:08 +08:00
ID : utility . GenerateUUID ( ) ,
feat[Go]: implement Create-Chat/Session, Delete-Session (#16386)
### What problem does this PR solve?
As title:
implement:
```go
chats.POST("", r.chatHandler.Create)
chats.POST("/:chat_id/sessions", r.chatSessionHandler.CreateSession)
chats.DELETE("/:chat_id/sessions", r.chatSessionHandler.DeleteSessions)
```
bug fixed:
https://github.com/infiniflow/ragflow/blob/f80d4c784368853ec727231b6891860629973519/internal/handler/chat.go#L84
↓
```go
result, err := h.chatService.ListChats(userID, "1", keywords, page, pageSize, orderby, desc)
```
### Type of change
- [x] Bug Fix (non-breaking change which fixes an issue)
- [x] New Feature (non-breaking change which adds functionality)
- [x] Refactoring
2026-06-26 19:23:45 +08:00
DialogID : chatID ,
Name : & name ,
Message : messagesJSON ,
UserID : & userID ,
Reference : referenceJSON ,
}
if err := s . chatSessionDAO . Create ( conv ) ; err != nil {
return nil , common . CodeDataError , errors . New ( "Fail to create a session!" )
}
session , err := s . chatSessionDAO . GetByID ( conv . ID )
if err != nil {
return nil , common . CodeDataError , errors . New ( "Fail to create a session!" )
}
return s . buildSessionPayload ( session , nil , false ) , common . CodeSuccess , nil
}
// DeleteSessions delete a session in a dialog
func ( s * ChatSessionService ) DeleteSessions ( userID , chatID string , req map [ string ] interface { } ) ( interface { } , string , common . ErrorCode , error ) {
ok , err := s . ensureOwnedChat ( userID , chatID )
if err != nil {
return nil , "" , common . CodeServerError , err
}
if ! ok {
return false , "No authorization." , common . CodeAuthenticationError , errors . New ( "No authorization." )
}
if len ( req ) == 0 {
return map [ string ] interface { } { } , "success" , common . CodeSuccess , nil
}
sessionIDs , hasIDs := stringSliceFromValue ( req [ "ids" ] )
if ! hasIDs || len ( sessionIDs ) == 0 {
deleteAll , _ := req [ "delete_all" ] . ( bool )
if deleteAll {
sessions , err := s . chatSessionDAO . ListByChatID ( chatID )
if err != nil {
return nil , "" , common . CodeServerError , err
}
for _ , session := range sessions {
sessionIDs = append ( sessionIDs , session . ID )
}
if len ( sessionIDs ) == 0 {
return map [ string ] interface { } { } , "success" , common . CodeSuccess , nil
}
} else {
return map [ string ] interface { } { } , "success" , common . CodeSuccess , nil
}
}
uniqueIDs , duplicateMessages := checkDuplicateChatSessionIDs ( sessionIDs )
errorsList := make ( [ ] string , 0 )
successCount := 0
for _ , sid := range uniqueIDs {
session , err := s . chatSessionDAO . GetBySessionIDAndChatID ( sid , chatID )
if err != nil {
errorsList = append ( errorsList , fmt . Sprintf ( "The chat doesn't own the session %s" , sid ) )
continue
}
s . removeSessionUploadFiles ( userID , session )
if err := s . chatSessionDAO . DeleteByID ( sid ) ; err != nil {
return nil , "" , common . CodeServerError , err
}
successCount ++
}
allErrors := append ( errorsList , duplicateMessages ... )
if len ( allErrors ) > 0 {
if successCount > 0 {
return map [ string ] interface { } {
"success_count" : successCount ,
"errors" : allErrors ,
} , fmt . Sprintf ( "Partially deleted %d sessions with %d errors" , successCount , len ( allErrors ) ) , common . CodeSuccess , nil
}
return nil , "" , common . CodeDataError , errors . New ( strings . Join ( allErrors , "; " ) )
}
return true , "success" , common . CodeSuccess , nil
}
func stringSliceFromValue ( value interface { } ) ( [ ] string , bool ) {
var raw [ ] interface { }
switch typed := value . ( type ) {
case [ ] interface { } :
raw = typed
case [ ] string :
raw = make ( [ ] interface { } , 0 , len ( typed ) )
for _ , item := range typed {
raw = append ( raw , item )
}
default :
return nil , false
}
ids := make ( [ ] string , 0 , len ( raw ) )
for _ , item := range raw {
id , ok := item . ( string )
if ! ok {
continue
}
if strings . TrimSpace ( id ) == "" {
continue
}
ids = append ( ids , id )
}
return ids , true
}
func ( s * ChatSessionService ) removeSessionUploadFiles ( userID string , session * entity . ChatSession ) {
messages := parseMessages ( session . Message )
bucket := fmt . Sprintf ( "%s-downloads" , userID )
storageImpl := storage . GetStorageFactory ( ) . GetStorage ( )
if storageImpl == nil {
common . Warn ( "storage is not initialized; skip chat upload cleanup" , zap . String ( "bucket" , bucket ) )
return
}
for _ , msg := range messages {
files , ok := msg [ "files" ] . ( [ ] interface { } )
if ! ok {
continue
}
for _ , item := range files {
file , ok := item . ( map [ string ] interface { } )
if ! ok {
continue
}
fileID , ok := file [ "id" ] . ( string )
if ! ok || fileID == "" {
continue
}
if err := storageImpl . Remove ( bucket , fileID ) ; err != nil {
common . Warn ( "Failed to delete chat upload blob" ,
zap . String ( "bucket" , bucket ) ,
zap . String ( "file_id" , fileID ) ,
zap . Error ( err ) ,
)
}
}
}
}
func checkDuplicateChatSessionIDs ( ids [ ] string ) ( [ ] string , [ ] string ) {
idCount := make ( map [ string ] int , len ( ids ) )
uniqueIDs := make ( [ ] string , 0 , len ( ids ) )
for _ , id := range ids {
id = strings . TrimSpace ( id )
if id == "" {
continue
}
idCount [ id ] ++
if idCount [ id ] == 1 {
uniqueIDs = append ( uniqueIDs , id )
}
}
duplicateMessages := make ( [ ] string , 0 )
for id , count := range idCount {
if count > 1 {
duplicateMessages = append ( duplicateMessages , fmt . Sprintf ( "Duplicate session ids: %s" , id ) )
}
}
return uniqueIDs , duplicateMessages
}
2026-06-24 17:34:01 +08:00
// UpdateSession updates one chat session after Python-style field validation.
func ( s * ChatSessionService ) UpdateSession ( userID , chatID , sessionID string , req map [ string ] interface { } ) ( * ChatSessionPayload , common . ErrorCode , error ) {
ok , err := s . ensureOwnedChat ( userID , chatID )
if err != nil {
return nil , common . CodeServerError , err
}
if ! ok {
return nil , common . CodeAuthenticationError , errors . New ( "No authorization." )
}
if len ( req ) == 0 {
return nil , common . CodeArgumentError , errors . New ( "Request body cannot be empty" )
}
if _ , err := s . chatSessionDAO . GetBySessionIDAndChatID ( sessionID , chatID ) ; err != nil {
if isChatSessionNotFound ( err ) {
return nil , common . CodeDataError , errors . New ( "Session not found!" )
}
return nil , common . CodeServerError , err
}
if _ , ok := req [ "message" ] ; ok {
return nil , common . CodeDataError , errors . New ( "`messages` cannot be changed." )
}
if _ , ok := req [ "messages" ] ; ok {
return nil , common . CodeDataError , errors . New ( "`messages` cannot be changed." )
}
if _ , ok := req [ "reference" ] ; ok {
return nil , common . CodeDataError , errors . New ( "`reference` cannot be changed." )
}
if name , exists := req [ "name" ] ; exists && name != nil {
nameStr , ok := name . ( string )
if ! ok || strings . TrimSpace ( nameStr ) == "" {
return nil , common . CodeDataError , errors . New ( "`name` can not be empty." )
}
req [ "name" ] = strings . TrimSpace ( nameStr )
nameRunes := [ ] rune ( req [ "name" ] . ( string ) )
if len ( nameRunes ) > 255 {
req [ "name" ] = string ( nameRunes [ : 255 ] )
}
}
updateFields := make ( map [ string ] interface { } )
for k , v := range req {
switch k {
case "id" , "dialog_id" , "chat_id" , "user_id" :
continue
default :
updateFields [ k ] = v
}
}
if err := s . chatSessionDAO . UpdateByID ( sessionID , updateFields ) ; err != nil {
if isChatSessionNotFound ( err ) {
return nil , common . CodeDataError , errors . New ( "Session not found!" )
}
return nil , common . CodeServerError , err
}
session , err := s . chatSessionDAO . GetByID ( sessionID )
if err != nil {
if isChatSessionNotFound ( err ) {
return nil , common . CodeDataError , errors . New ( "Fail to update a session!" )
}
return nil , common . CodeServerError , err
}
return s . buildSessionPayload ( session , nil , false ) , common . CodeSuccess , nil
}
2026-06-30 16:28:48 +08:00
func ( s * ChatSessionService ) DeleteSessionMessage ( userID , chatID , sessionID , msgID string ) ( * ChatSessionPayload , common . ErrorCode , error ) {
ok , err := s . ensureOwnedChat ( userID , chatID )
if err != nil {
return nil , common . CodeServerError , err
}
if ! ok {
return nil , common . CodeAuthenticationError , errors . New ( "No authorization." )
}
session , err := s . chatSessionDAO . GetByID ( sessionID )
if err != nil || session . DialogID != chatID {
if err != nil && ! isChatSessionNotFound ( err ) {
return nil , common . CodeServerError , err
}
return nil , common . CodeDataError , errors . New ( "Session not found!" )
}
// parseMessages / parseReferenceList return nil for
// malformed input, so the existing `nil` guards below are
// the single source of truth for "this blob is corrupt".
messages := parseMessages ( session . Message )
if len ( session . Message ) > 0 && messages == nil {
return nil , common . CodeDataError , errors . New ( "Invalid session messages" )
}
references := parseReferenceList ( session . Reference )
if len ( session . Reference ) > 0 && references == nil {
return nil , common . CodeDataError , errors . New ( "Invalid session reference" )
}
for i , msg := range messages {
if msgID != stringValue ( msg [ "id" ] ) {
continue
}
if i + 1 >= len ( messages ) || stringValue ( messages [ i + 1 ] [ "id" ] ) != msgID {
return nil , common . CodeServerError , errors . New ( "message pair assertion failed" )
}
messages = append ( messages [ : i ] , messages [ i + 2 : ] ... )
refIndex := ( i - 1 ) / 2
if refIndex < 0 {
refIndex = 0
}
if refIndex < len ( references ) {
references = append ( references [ : refIndex ] , references [ refIndex + 1 : ] ... )
}
break
}
messageRaw , err := json . Marshal ( messages )
if err != nil {
return nil , common . CodeServerError , err
}
referenceRaw , err := json . Marshal ( references )
if err != nil {
return nil , common . CodeServerError , err
}
if err := s . chatSessionDAO . UpdateByID ( session . ID , map [ string ] interface { } {
"message" : messageRaw ,
"reference" : referenceRaw ,
} ) ; err != nil {
return nil , common . CodeServerError , err
}
session . Message = messageRaw
session . Reference = referenceRaw
return s . buildSessionPayload ( session , nil , false ) , common . CodeSuccess , nil
}
2026-07-02 10:33:27 +08:00
func ( s * ChatSessionService ) UpdateMessageFeedback ( ctx context . Context , userID , chatID , sessionID , msgID string , req map [ string ] interface { } ) ( * ChatSessionPayload , common . ErrorCode , error ) {
if ctx == nil {
ctx = context . Background ( )
}
2026-06-30 16:28:48 +08:00
ownerTenantID := ""
tenantIDs , err := s . userTenantDAO . GetTenantIDsByUserID ( userID )
if err != nil {
return nil , common . CodeServerError , err
}
for _ , tenantID := range tenantIDs {
exists , err := s . chatSessionDAO . CheckDialogExists ( tenantID , chatID )
if err != nil {
return nil , common . CodeServerError , err
}
if exists {
ownerTenantID = tenantID
break
}
}
if ownerTenantID == "" {
exists , err := s . chatSessionDAO . CheckDialogExists ( userID , chatID )
if err != nil {
return nil , common . CodeServerError , err
}
if exists {
ownerTenantID = userID
}
}
ok := ownerTenantID != ""
if ! ok {
return nil , common . CodeAuthenticationError , errors . New ( "No authorization." )
}
session , err := s . chatSessionDAO . GetByID ( sessionID )
if err != nil || session . DialogID != chatID {
if err != nil && ! isChatSessionNotFound ( err ) {
return nil , common . CodeServerError , err
}
return nil , common . CodeDataError , errors . New ( "Session not found!" )
}
thumbRaw , ok := req [ "thumbup" ]
if ! ok {
return nil , common . CodeDataError , errors . New ( "thumbup must be a boolean" )
}
thumbup , ok := thumbRaw . ( bool )
if ! ok {
return nil , common . CodeDataError , errors . New ( "thumbup must be a boolean" )
}
messages := parseMessages ( session . Message )
if len ( session . Message ) > 0 && messages == nil {
return nil , common . CodeDataError , errors . New ( "Invalid session messages" )
}
// References are only used later in this function but a
// malformed blob must surface immediately, not silently
// collapse to an empty slice.
references := parseReferenceList ( session . Reference )
if len ( session . Reference ) > 0 && references == nil {
return nil , common . CodeDataError , errors . New ( "Invalid session reference" )
}
messageIndex := - 1
var priorThumb interface { }
applyChunkFeedback := false
var feedbackReference map [ string ] interface { }
for i , msg := range messages {
if msgID != stringValue ( msg [ "id" ] ) || stringValue ( msg [ "role" ] ) != "assistant" {
continue
}
priorThumb = msg [ "thumbup" ]
priorThumbBool , priorThumbIsBool := priorThumb . ( bool )
if thumbup {
msg [ "thumbup" ] = true
delete ( msg , "feedback" )
applyChunkFeedback = ! priorThumbIsBool || ! priorThumbBool
} else {
msg [ "thumbup" ] = false
if feedback , exists := req [ "feedback" ] ; exists && isTruthy ( feedback ) {
msg [ "feedback" ] = feedback
}
applyChunkFeedback = ! priorThumbIsBool || priorThumbBool
}
messages [ i ] = msg
messageIndex = i
break
}
if messageIndex != - 1 && applyChunkFeedback {
references := parseReferenceList ( session . Reference )
if len ( session . Reference ) > 0 && references == nil {
return nil , common . CodeDataError , errors . New ( "Invalid session reference" )
}
refIndex := ( messageIndex - 1 ) / 2
if refIndex >= 0 && refIndex < len ( references ) {
if reference , ok := references [ refIndex ] . ( map [ string ] interface { } ) ; ok && len ( reference ) > 0 {
feedbackReference = reference
}
}
}
messageRaw , err := json . Marshal ( messages )
if err != nil {
return nil , common . CodeServerError , err
}
if err := s . chatSessionDAO . UpdateByID ( session . ID , map [ string ] interface { } { "message" : messageRaw } ) ; err != nil {
return nil , common . CodeServerError , err
}
session . Message = messageRaw
if feedbackReference != nil {
applier := s . chunkFeedbackApplier
if applier == nil {
applier = s
}
if priorThumbBool , ok := priorThumb . ( bool ) ; ok && priorThumbBool != thumbup {
2026-07-02 10:33:27 +08:00
result , _ := applier . applyChunkFeedback ( ctx , ownerTenantID , feedbackReference , ! priorThumbBool )
2026-06-30 16:28:48 +08:00
if result != nil {
common . Debug ( "Chunk feedback undo applied" ,
zap . Any ( "success_count" , result [ "success_count" ] ) ,
zap . Any ( "fail_count" , result [ "fail_count" ] ) ,
)
}
}
2026-07-02 10:33:27 +08:00
result , _ := applier . applyChunkFeedback ( ctx , ownerTenantID , feedbackReference , thumbup )
2026-06-30 16:28:48 +08:00
if result != nil {
common . Debug ( "Chunk feedback applied" ,
zap . Any ( "success_count" , result [ "success_count" ] ) ,
zap . Any ( "fail_count" , result [ "fail_count" ] ) ,
)
}
}
return s . buildSessionPayload ( session , nil , false ) , common . CodeSuccess , nil
}
2026-07-02 10:33:27 +08:00
const (
upvoteWeightIncrement = 1
downvoteWeightDecrement = 1
minPagerankWeight = 0.0
maxPagerankWeight = 100.0
chunkFeedbackTimeout = 30 * time . Second
)
type feedbackChunkRow struct {
chunkID string
kbID string
chunk map [ string ] interface { }
}
// applyChunkFeedback records a thumb vote against the chunks that produced a
// session message. It mirrors Python's ChunkFeedbackService.apply_feedback:
// feature-flagged by CHUNK_FEEDBACK_ENABLED, split by relevance unless
// CHUNK_FEEDBACK_WEIGHTING=uniform, and clamped through the document engine.
func ( s * ChatSessionService ) applyChunkFeedback ( ctx context . Context , tenantID string , reference map [ string ] interface { } , isPositive bool ) ( map [ string ] interface { } , error ) {
if ! chunkFeedbackEnabled ( ) {
common . Debug ( "Chunk feedback feature is disabled" )
return map [ string ] interface { } {
"success_count" : 0 ,
"fail_count" : 0 ,
"chunk_ids" : [ ] string { } ,
"disabled" : true ,
} , nil
}
if ctx == nil {
ctx = context . Background ( )
}
if _ , ok := ctx . Deadline ( ) ; ! ok {
var cancel context . CancelFunc
ctx , cancel = context . WithTimeout ( ctx , chunkFeedbackTimeout )
defer cancel ( )
}
rows := feedbackRowsFromReference ( reference )
chunkIDs := make ( [ ] string , 0 , len ( rows ) )
for _ , row := range rows {
chunkIDs = append ( chunkIDs , row . chunkID )
}
if len ( rows ) == 0 {
common . Debug ( "No chunk IDs found in reference for feedback" )
return map [ string ] interface { } {
"success_count" : 0 ,
"fail_count" : 0 ,
"chunk_ids" : chunkIDs ,
} , nil
}
signedBudget := float64 ( upvoteWeightIncrement )
if ! isPositive {
signedBudget = - float64 ( downvoteWeightDecrement )
}
deltas := allocateFeedbackDeltas ( rows , signedBudget , chunkFeedbackWeighting ( ) )
successCount := 0
failCount := 0
for _ , delta := range deltas {
if delta . delta == 0 {
continue
}
if s . updateChunkWeight ( ctx , tenantID , delta . chunkID , delta . kbID , delta . delta ) {
successCount ++
} else {
failCount ++
}
}
common . Info ( "Applied chunk feedback" ,
2026-06-30 16:28:48 +08:00
zap . Bool ( "is_positive" , isPositive ) ,
2026-07-02 10:33:27 +08:00
zap . String ( "weighting" , chunkFeedbackWeighting ( ) ) ,
zap . Int ( "success_count" , successCount ) ,
zap . Int ( "chunk_count" , len ( chunkIDs ) ) ,
2026-06-30 16:28:48 +08:00
)
return map [ string ] interface { } {
2026-07-02 10:33:27 +08:00
"success_count" : successCount ,
"fail_count" : failCount ,
"chunk_ids" : chunkIDs ,
2026-06-30 16:28:48 +08:00
} , nil
}
2026-07-02 10:33:27 +08:00
type feedbackDelta struct {
chunkID string
kbID string
delta float64
}
func chunkFeedbackEnabled ( ) bool {
2026-07-10 11:58:32 +08:00
return common . GetEnv ( common . EnvChunkFeedbackEnabled ) == "true"
2026-07-02 10:33:27 +08:00
}
func chunkFeedbackWeighting ( ) string {
2026-07-10 11:58:32 +08:00
weighting := strings . TrimSpace ( common . GetEnvSmall ( common . EnvChunkFeedbackWeighting ) )
2026-07-02 10:33:27 +08:00
if weighting == "uniform" || weighting == "relevance" {
return weighting
}
return "relevance"
}
func feedbackRowsFromReference ( reference map [ string ] interface { } ) [ ] feedbackChunkRow {
if len ( reference ) == 0 {
return nil
}
rawChunks , ok := reference [ "chunks" ] . ( [ ] interface { } )
if ! ok {
if chunks , ok := reference [ "chunks" ] . ( [ ] map [ string ] interface { } ) ; ok {
rows := make ( [ ] feedbackChunkRow , 0 , len ( chunks ) )
for _ , chunk := range chunks {
if row , ok := feedbackRowFromChunk ( chunk ) ; ok {
rows = append ( rows , row )
}
}
return rows
}
return nil
}
rows := make ( [ ] feedbackChunkRow , 0 , len ( rawChunks ) )
for _ , raw := range rawChunks {
chunk , ok := raw . ( map [ string ] interface { } )
if ! ok {
continue
}
if row , ok := feedbackRowFromChunk ( chunk ) ; ok {
rows = append ( rows , row )
}
}
return rows
}
func feedbackRowFromChunk ( chunk map [ string ] interface { } ) ( feedbackChunkRow , bool ) {
chunkID := stringValue ( chunk [ "id" ] )
if chunkID == "" {
chunkID = stringValue ( chunk [ "chunk_id" ] )
}
kbID := stringValue ( chunk [ "dataset_id" ] )
if kbID == "" {
kbID = stringValue ( chunk [ "kb_id" ] )
}
if chunkID == "" || kbID == "" {
return feedbackChunkRow { } , false
}
return feedbackChunkRow { chunkID : chunkID , kbID : kbID , chunk : chunk } , true
}
func allocateFeedbackDeltas ( rows [ ] feedbackChunkRow , signedBudget float64 , weighting string ) [ ] feedbackDelta {
if len ( rows ) == 0 {
return nil
}
if weighting == "uniform" {
step := signedBudget / float64 ( len ( rows ) )
deltas := make ( [ ] feedbackDelta , 0 , len ( rows ) )
for _ , row := range rows {
deltas = append ( deltas , feedbackDelta { chunkID : row . chunkID , kbID : row . kbID , delta : step } )
}
return deltas
}
magnitudes := make ( [ ] float64 , 0 , len ( rows ) )
for _ , row := range rows {
signal := retrievalSignal ( row . chunk )
if signal <= 0 {
signal = 1
}
magnitudes = append ( magnitudes , signal )
}
parts := splitFloatBudget ( magnitudes , math . Abs ( signedBudget ) )
sign := math . Copysign ( 1 , signedBudget )
deltas := make ( [ ] feedbackDelta , 0 , len ( rows ) )
for i , row := range rows {
deltas = append ( deltas , feedbackDelta { chunkID : row . chunkID , kbID : row . kbID , delta : sign * parts [ i ] } )
}
return deltas
}
func retrievalSignal ( chunk map [ string ] interface { } ) float64 {
best := 0.0
for _ , key := range [ ] string { "similarity" , "vector_similarity" , "term_similarity" } {
val , ok := floatValue ( chunk [ key ] )
if ok && ! math . IsInf ( val , 0 ) && ! math . IsNaN ( val ) && val > best {
best = val
}
}
return best
}
func splitFloatBudget ( magnitudes [ ] float64 , budget float64 ) [ ] float64 {
n := len ( magnitudes )
out := make ( [ ] float64 , n )
if n == 0 || budget == 0 {
return out
}
total := 0.0
for _ , magnitude := range magnitudes {
total += magnitude
}
if total <= 0 {
base := budget / float64 ( n )
for i := range out {
out [ i ] = base
}
return out
}
for i , magnitude := range magnitudes {
out [ i ] = budget * magnitude / total
}
return out
}
func ( s * ChatSessionService ) updateChunkWeight ( ctx context . Context , tenantID , chunkID , kbID string , delta float64 ) bool {
docEngine := s . docEngine
if docEngine == nil {
docEngine = engine . Get ( )
}
if docEngine == nil {
common . Warn ( "Document engine is not initialized; chunk feedback skipped" ,
zap . String ( "chunk_id" , chunkID ) ,
zap . String ( "kb_id" , kbID ) ,
)
return false
}
indexName := fmt . Sprintf ( "ragflow_%s" , tenantID )
if adjuster , ok := docEngine . ( chunkPagerankAdjuster ) ; ok {
if err := adjuster . AdjustChunkPagerank ( ctx , indexName , chunkID , kbID , delta , minPagerankWeight , maxPagerankWeight ) ; err != nil {
common . Warn ( "Failed atomic pagerank adjust for chunk" ,
zap . String ( "chunk_id" , chunkID ) ,
zap . Error ( err ) ,
)
return false
}
return true
}
rawChunk , err := docEngine . GetChunk ( ctx , indexName , chunkID , [ ] string { kbID } )
if err != nil {
common . Warn ( "Chunk not found for feedback" ,
zap . String ( "chunk_id" , chunkID ) ,
zap . String ( "index" , indexName ) ,
zap . Error ( err ) ,
)
return false
}
chunk , ok := rawChunk . ( map [ string ] interface { } )
if ! ok {
common . Warn ( "Unexpected chunk shape for feedback" ,
zap . String ( "chunk_id" , chunkID ) ,
zap . String ( "kb_id" , kbID ) ,
zap . String ( "index" , indexName ) ,
zap . String ( "chunk_type" , fmt . Sprintf ( "%T" , rawChunk ) ) ,
)
return false
}
currentWeight , _ := floatValue ( chunk [ common . PAGERANK_FLD ] )
nextWeight := currentWeight + delta
if nextWeight < minPagerankWeight {
nextWeight = minPagerankWeight
}
if nextWeight > maxPagerankWeight {
nextWeight = maxPagerankWeight
}
newValue := map [ string ] interface { } { common . PAGERANK_FLD : nextWeight }
if nextWeight <= 0 && strings . ToLower ( docEngine . GetType ( ) ) == string ( engine . EngineElasticsearch ) {
newValue = map [ string ] interface { } { "remove" : common . PAGERANK_FLD }
}
if err := docEngine . UpdateChunks ( ctx , map [ string ] interface { } { "id" : chunkID } , newValue , indexName , kbID ) ; err != nil {
common . Warn ( "Failed to update chunk pagerank" ,
zap . String ( "chunk_id" , chunkID ) ,
zap . Error ( err ) ,
)
return false
}
return true
}
func floatValue ( value interface { } ) ( float64 , bool ) {
switch v := value . ( type ) {
case float64 :
return v , true
case float32 :
return float64 ( v ) , true
case int :
return float64 ( v ) , true
case int64 :
return float64 ( v ) , true
case int32 :
return float64 ( v ) , true
case json . Number :
f , err := v . Float64 ( )
return f , err == nil
case string :
f , err := strconv . ParseFloat ( strings . TrimSpace ( v ) , 64 )
return f , err == nil
default :
return 0 , false
}
}
2026-06-24 17:34:01 +08:00
func ( s * ChatSessionService ) ensureOwnedChat ( userID , chatID string ) ( bool , error ) {
tenantIDs , err := s . userTenantDAO . GetTenantIDsByUserID ( userID )
if err != nil {
return false , err
}
for _ , tenantID := range tenantIDs {
exists , err := s . chatSessionDAO . CheckDialogExists ( tenantID , chatID )
if err != nil {
return false , err
}
if exists {
return true , nil
}
}
exists , err := s . chatSessionDAO . CheckDialogExists ( userID , chatID )
if err != nil {
return false , err
}
return exists , nil
}
func ( s * ChatSessionService ) buildSessionPayload ( session * entity . ChatSession , dialog * entity . Chat , includeAvatar bool ) * ChatSessionPayload {
var avatar * string
if includeAvatar {
value := ""
if dialog != nil && dialog . Icon != nil {
value = * dialog . Icon
}
avatar = & value
}
references := parseReferenceList ( session . Reference )
for index , ref := range references {
refMap , ok := ref . ( map [ string ] interface { } )
if ! ok {
continue
}
refMap [ "chunks" ] = formatReferenceChunks ( refMap )
references [ index ] = refMap
}
return & ChatSessionPayload {
ID : session . ID ,
ChatID : session . DialogID ,
Name : session . Name ,
Messages : parseMessages ( session . Message ) ,
Reference : references ,
UserID : session . UserID ,
Avatar : avatar ,
CreateDate : session . CreateDate ,
UpdateDate : session . UpdateDate ,
CreateTime : session . CreateTime ,
UpdateTime : session . UpdateTime ,
}
}
2026-06-30 16:28:48 +08:00
// parseMessages decodes a session.Message blob. Returns:
// - nil — input was non-empty but malformed JSON;
// callers should reject with "Invalid session messages".
// - non-nil empty slice — input was empty (no messages stored yet).
// - non-nil slice — successful decode.
//
// The nil-on-malformed contract is what makes the
// `if len(raw) > 0 && messages == nil` checks in
// DeleteSessionMessage / UpdateMessageFeedback fire — without it,
// a corrupted blob silently parses to an empty slice and the
// message-repair logic no-ops. PR review round 7 (CI red): the
// helpers previously returned `make([]T, 0)` on parse failure,
// so the upstream-introduced guards in a55388698 never matched.
2026-06-24 17:34:01 +08:00
func parseMessages ( raw json . RawMessage ) [ ] map [ string ] interface { } {
2026-06-29 19:04:59 +08:00
messages := make ( [ ] map [ string ] interface { } , 0 )
2026-06-24 17:34:01 +08:00
if len ( raw ) == 0 {
return messages
}
if err := json . Unmarshal ( raw , & messages ) ; err == nil {
2026-06-29 19:04:59 +08:00
if messages == nil {
return make ( [ ] map [ string ] interface { } , 0 )
}
2026-06-24 17:34:01 +08:00
return messages
}
2026-06-29 20:07:12 +08:00
var wrapped map [ string ] json . RawMessage
2026-06-24 17:34:01 +08:00
if err := json . Unmarshal ( raw , & wrapped ) ; err != nil {
2026-06-29 20:07:12 +08:00
return nil
}
wrappedMessages , ok := wrapped [ "messages" ]
if ! ok {
2026-06-30 16:28:48 +08:00
// Object without a "messages" key — wrong schema, not empty.
2026-06-29 20:07:12 +08:00
return nil
}
if len ( wrappedMessages ) == 0 || string ( wrappedMessages ) == "null" {
2026-06-30 16:28:48 +08:00
// Empty / explicit-null wrapped messages is a legitimate
// empty form, not a parse failure. Return non-nil empty
// so the service layer's `messages == nil` check
// correctly distinguishes "no messages yet" from "corrupt
// blob". (Upstream fix: the original code returned nil
// here too, which made the test
// TestParseCollections_ReturnEmptySlicesForMissingOrNull
// fail — `{"messages":null}` is a valid empty form, not
// a malformed blob.)
2026-06-29 19:04:59 +08:00
return make ( [ ] map [ string ] interface { } , 0 )
}
2026-06-29 20:07:12 +08:00
if err := json . Unmarshal ( wrappedMessages , & messages ) ; err != nil {
return nil
}
if messages == nil {
2026-06-29 19:04:59 +08:00
return make ( [ ] map [ string ] interface { } , 0 )
2026-06-24 17:34:01 +08:00
}
2026-06-29 20:07:12 +08:00
return messages
2026-06-24 17:34:01 +08:00
}
2026-06-30 16:28:48 +08:00
// parseReferenceList decodes a session.Reference blob. Same
// nil-on-malformed contract as parseMessages — callers gate on
// `if len(raw) > 0 && references == nil` to reject corruption.
2026-06-24 17:34:01 +08:00
func parseReferenceList ( raw json . RawMessage ) [ ] interface { } {
2026-06-29 19:04:59 +08:00
references := make ( [ ] interface { } , 0 )
2026-06-24 17:34:01 +08:00
if len ( raw ) == 0 {
return references
}
2026-06-30 16:28:48 +08:00
if err := json . Unmarshal ( raw , & references ) ; err != nil {
2026-06-29 20:07:12 +08:00
return nil
2026-06-29 19:04:59 +08:00
}
if references == nil {
return make ( [ ] interface { } , 0 )
2026-06-24 17:34:01 +08:00
}
return references
}
func formatReferenceChunks ( reference map [ string ] interface { } ) [ ] FormattedChunk {
rawChunks , ok := reference [ "chunks" ] . ( [ ] interface { } )
if ! ok {
return [ ] FormattedChunk { }
}
chunks := make ( [ ] map [ string ] interface { } , 0 , len ( rawChunks ) )
for _ , item := range rawChunks {
chunk , ok := item . ( map [ string ] interface { } )
if ! ok {
continue
}
chunks = append ( chunks , chunk )
}
return formatChunks ( chunks )
}
func isChatSessionNotFound ( err error ) bool {
return errors . Is ( err , gorm . ErrRecordNotFound )
}
2026-07-02 10:33:27 +08:00
// Completion performs chat completion with full RAG support via ChatPipelineService.
// Kept as a compatibility entrypoint for callers that still use the pre-ChatCompletions API.
func ( s * ChatSessionService ) Completion ( userID string , conversationID string , messages [ ] map [ string ] interface { } , llmID string , chatModelConfig map [ string ] interface { } , messageID string ) ( map [ string ] interface { } , error ) {
if len ( messages ) == 0 {
return nil , errors . New ( "messages cannot be empty" )
}
lastRole , _ := messages [ len ( messages ) - 1 ] [ "role" ] . ( string )
if lastRole != "user" {
return nil , errors . New ( "the last content of this conversation is not from user" )
}
session , err := s . chatSessionDAO . GetByID ( conversationID )
if err != nil {
return nil , errors . New ( "Conversation not found" )
}
dialog , err := s . chatSessionDAO . GetDialogByID ( session . DialogID )
if err != nil {
return nil , errors . New ( "Dialog not found" )
}
sessionMessages := s . buildSessionMessages ( session , messages )
reference := s . initializeReference ( session )
isEmbedded := llmID != ""
if llmID != "" {
hasKey , err := s . checkTenantLLMAPIKey ( dialog . TenantID , llmID )
if err != nil || ! hasKey {
return nil , fmt . Errorf ( "Cannot use specified model %s" , llmID )
}
dialog . LLMID = llmID
if chatModelConfig != nil {
dialog . LLMSetting = chatModelConfig
}
}
kwargs := chatModelConfig
if kwargs == nil {
kwargs = map [ string ] interface { } { }
}
2026-07-06 10:13:46 +08:00
resultChan , err := s . pipeline . AsyncChat ( context . Background ( ) , userID , dialog , messages , false , kwargs )
2026-07-02 10:33:27 +08:00
if err != nil {
return nil , err
}
var answer strings . Builder
var finalRef map [ string ] interface { }
for result := range resultChan {
if result . Answer != "" {
answer . WriteString ( result . Answer )
}
if result . Reference != nil {
finalRef = result . Reference
}
}
ans := map [ string ] interface { } {
"answer" : answer . String ( ) ,
"reference" : finalRef ,
"final" : true ,
}
result := s . structureAnswerWithConv ( session , ans , messageID , session . ID , reference )
if ! isEmbedded {
sessionMessages = append ( sessionMessages , map [ string ] interface { } {
"role" : "assistant" ,
"content" : answer . String ( ) ,
"id" : messageID ,
"created_at" : float64 ( time . Now ( ) . Unix ( ) ) ,
} )
s . updateSessionMessages ( session , sessionMessages , reference )
}
return result , nil
}
// CompletionStream performs streaming chat completion with full RAG support via ChatPipelineService.
// Kept as a compatibility entrypoint for callers that still use the pre-ChatCompletions API.
func ( s * ChatSessionService ) CompletionStream ( ctx context . Context , userID string , conversationID string , messages [ ] map [ string ] interface { } , llmID string , chatModelConfig map [ string ] interface { } , messageID string , streamChan chan <- string ) error {
if ctx == nil {
ctx = context . Background ( )
}
if len ( messages ) == 0 {
streamChan <- fmt . Sprintf ( "data: %s\n\n" , ` { "code": 500, "message": "messages cannot be empty", "data": { "answer": "**ERROR**: messages cannot be empty", "reference": []}} ` )
return errors . New ( "messages cannot be empty" )
}
lastRole , _ := messages [ len ( messages ) - 1 ] [ "role" ] . ( string )
if lastRole != "user" {
streamChan <- fmt . Sprintf ( "data: %s\n\n" , ` { "code": 500, "message": "the last content of this conversation is not from user", "data": { "answer": "**ERROR**: the last content of this conversation is not from user", "reference": []}} ` )
return errors . New ( "the last content of this conversation is not from user" )
}
session , err := s . chatSessionDAO . GetByID ( conversationID )
if err != nil {
streamChan <- fmt . Sprintf ( "data: %s\n\n" , ` { "code": 500, "message": "Conversation not found", "data": { "answer": "**ERROR**: Conversation not found", "reference": []}} ` )
return errors . New ( "Conversation not found" )
}
dialog , err := s . chatSessionDAO . GetDialogByID ( session . DialogID )
if err != nil {
streamChan <- fmt . Sprintf ( "data: %s\n\n" , ` { "code": 500, "message": "Dialog not found", "data": { "answer": "**ERROR**: Dialog not found", "reference": []}} ` )
return errors . New ( "Dialog not found" )
}
sessionMessages := s . buildSessionMessages ( session , messages )
reference := s . initializeReference ( session )
isEmbedded := llmID != ""
if llmID != "" {
hasKey , err := s . checkTenantLLMAPIKey ( dialog . TenantID , llmID )
if err != nil || ! hasKey {
errMsg := fmt . Sprintf ( ` { "code": 500, "message": "Cannot use specified model %s", "data": { "answer": "**ERROR**: Cannot use specified model", "reference": []}} ` , llmID )
streamChan <- fmt . Sprintf ( "data: %s\n\n" , errMsg )
return fmt . Errorf ( "Cannot use specified model %s" , llmID )
}
dialog . LLMID = llmID
if chatModelConfig != nil {
dialog . LLMSetting = chatModelConfig
}
}
kwargs := chatModelConfig
if kwargs == nil {
kwargs = map [ string ] interface { } { }
}
2026-07-06 10:13:46 +08:00
resultChan , err := s . pipeline . AsyncChat ( ctx , userID , dialog , messages , true , kwargs )
2026-07-02 10:33:27 +08:00
if err != nil {
streamChan <- fmt . Sprintf ( "data: %s\n\n" , fmt . Sprintf ( ` { "code": 500, "message": "%s", "data": { "answer": "**ERROR**: %s", "reference": []}} ` , err . Error ( ) , err . Error ( ) ) )
return err
}
var fullAnswer strings . Builder
for result := range resultChan {
if result . Reference != nil && len ( reference ) > 0 {
reference [ len ( reference ) - 1 ] = result . Reference
}
if result . Final {
if result . Answer != "" {
fullAnswer . Reset ( )
fullAnswer . WriteString ( result . Answer )
}
} else if result . Answer != "" {
fullAnswer . WriteString ( result . Answer )
}
ans := s . structureAnswer ( session , fullAnswer . String ( ) , messageID , session . ID , reference )
data , _ := json . Marshal ( map [ string ] interface { } {
"code" : 0 ,
"message" : "" ,
"data" : ans ,
} )
streamChan <- fmt . Sprintf ( "data: %s\n\n" , string ( data ) )
}
finalData , _ := json . Marshal ( map [ string ] interface { } {
"code" : 0 ,
"message" : "" ,
"data" : true ,
} )
streamChan <- fmt . Sprintf ( "data: %s\n\n" , string ( finalData ) )
if ! isEmbedded {
sessionMessages = append ( sessionMessages , map [ string ] interface { } {
"role" : "assistant" ,
"content" : fullAnswer . String ( ) ,
"id" : messageID ,
"created_at" : float64 ( time . Now ( ) . Unix ( ) ) ,
} )
s . updateSessionMessages ( session , sessionMessages , reference )
}
return nil
}
2026-07-01 15:52:52 +08:00
// ChatCompletions handles chat completion matching Python's session_completion.
// When stream=true, returns nil result and streams SSE via streamChan.
// When stream=false, returns the structured answer map.
func ( s * ChatSessionService ) ChatCompletions (
ctx context . Context ,
userID string ,
chatID string , sessionID string ,
messages [ ] map [ string ] interface { } , question string , files [ ] interface { } ,
llmID string , genConfig map [ string ] interface { } , kwargs map [ string ] interface { } ,
passAllHistory bool , legacy bool ,
stream bool , streamChan chan <- string ,
) ( map [ string ] interface { } , error ) {
if ctx == nil {
ctx = context . Background ( )
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
fail := func ( err error ) ( map [ string ] interface { } , error ) {
if stream && streamChan != nil {
s . sendSSEError ( streamChan , err . Error ( ) )
}
return nil , err
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
sendOrCancel := func ( data string ) bool {
select {
case streamChan <- data :
return true
case <- ctx . Done ( ) :
return false
}
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
common . Info ( "ChatCompletions started" )
// --- 1. Normalize messages ---
requestMessages , requestMsg , messageID , err := s . normalizeCompletionMessages ( messages , question , files )
2026-03-04 19:17:16 +08:00
if err != nil {
2026-07-01 15:52:52 +08:00
return fail ( err )
}
// --- 2. Validate ---
if sessionID != "" && chatID == "" {
return fail ( errors . New ( "`chat_id` is required when `session_id` is provided." ) )
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
// --- 3. Resolve dialog and session ---
var dialog * entity . Chat
var session * entity . ChatSession
if chatID != "" {
if err := s . checkDialogOwnership ( userID , chatID ) ; err != nil {
return fail ( err )
}
dialog , err = s . chatSessionDAO . GetDialogByID ( chatID )
if err != nil {
return fail ( errors . New ( "Chat not found!" ) )
}
if sessionID != "" {
session , err = s . chatSessionDAO . GetByID ( sessionID )
if err != nil {
return fail ( errors . New ( "Session not found!" ) )
}
if session . DialogID != chatID {
return fail ( errors . New ( "Session does not belong to this chat!" ) )
}
} else {
session , err = s . createSessionForCompletion ( chatID , dialog , userID )
if err != nil {
return fail ( err )
}
sessionID = session . ID
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
if passAllHistory {
session . Message , _ = json . Marshal ( requestMessages )
} else {
session = s . appendSessionMessage ( session , requestMsg )
}
requestMsg = s . filterSystemAndLeadingAssistant ( session )
_ = messageID
} else {
dialog = s . buildDefaultCompletionDialog ( userID )
if ! stream {
genConfig [ "stream" ] = false
}
}
// --- 4. Initialize reference ---
var reference [ ] interface { }
if session != nil {
reference = s . initializeReference ( session )
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// --- 5. LLM override ---
if genConfig == nil {
genConfig = map [ string ] interface { } { }
}
2026-03-04 19:17:16 +08:00
if llmID != "" {
hasKey , err := s . checkTenantLLMAPIKey ( dialog . TenantID , llmID )
if err != nil || ! hasKey {
2026-07-01 15:52:52 +08:00
return fail ( fmt . Errorf ( "Cannot use specified model %s" , llmID ) )
2026-03-04 19:17:16 +08:00
}
dialog . LLMID = llmID
2026-07-01 15:52:52 +08:00
dialog . LLMSetting = genConfig
} else if dialog . LLMID == "" {
tenant , err := dao . NewTenantDAO ( ) . GetByID ( dialog . TenantID )
if err != nil || tenant . LLMID == "" {
return fail ( errors . New ( "No default chat model for tenant." ) )
}
dialog . LLMID = tenant . LLMID
if dialog . LLMSetting == nil {
dialog . LLMSetting = entity . JSONMap { }
}
for k , v := range genConfig {
dialog . LLMSetting [ k ] = v
2026-03-04 19:17:16 +08:00
}
}
2026-06-18 18:07:27 +08:00
if kwargs == nil {
kwargs = map [ string ] interface { } { }
}
2026-07-01 15:52:52 +08:00
for k , v := range genConfig {
kwargs [ k ] = v
}
// --- 6. Run pipeline ---
2026-07-06 10:13:46 +08:00
resultChan , err := s . pipeline . AsyncChat ( ctx , userID , dialog , requestMsg , stream , kwargs )
2026-03-04 19:17:16 +08:00
if err != nil {
2026-07-01 15:52:52 +08:00
return fail ( err )
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
if stream && streamChan != nil {
var fullAnswer strings . Builder
var finalLegacyAnswer map [ string ] interface { }
for result := range resultChan {
if result . Reference != nil && len ( reference ) > 0 {
reference [ len ( reference ) - 1 ] = result . Reference
}
if legacy {
if result . Final {
if strings . Contains ( result . Answer , "**ERROR**" ) {
ans := s . structureAnswer ( session , result . Answer , messageID , sessionID , reference )
if chatID != "" {
ans [ "chat_id" ] = chatID
}
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , sseMarshalChunk ( sanitizeJSONFloats ( ans ) . ( map [ string ] interface { } ) , chatID ) ) )
}
finalLegacyAnswer = s . structureAnswer ( session , result . Answer , messageID , sessionID , reference )
continue
}
if result . StartToThink {
fullAnswer . WriteString ( "<think>" )
} else if result . EndToThink {
fullAnswer . WriteString ( "</think>" )
} else if result . Answer != "" {
fullAnswer . WriteString ( result . Answer )
}
if session != nil {
s . appendAssistantToSession ( session , fullAnswer . String ( ) , messageID )
}
ans := s . structureAnswer ( session , fullAnswer . String ( ) , messageID , sessionID , reference )
ans [ "start_to_think" ] = nil
ans [ "end_to_think" ] = nil
delete ( ans , "start_to_think" )
delete ( ans , "end_to_think" )
if chatID != "" {
ans [ "chat_id" ] = chatID
}
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , sseMarshalChunk ( sanitizeJSONFloats ( ans ) . ( map [ string ] interface { } ) , chatID ) ) )
} else {
if result . Final {
if strings . Contains ( result . Answer , "**ERROR**" ) {
ans := s . structureAnswer ( session , result . Answer , messageID , sessionID , reference )
if chatID != "" {
ans [ "chat_id" ] = chatID
}
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , sseMarshalChunk ( sanitizeJSONFloats ( ans ) . ( map [ string ] interface { } ) , chatID ) ) )
2026-07-02 20:19:50 +08:00
} else {
ans := s . structureAnswer ( session , "" , messageID , sessionID , reference )
ans [ "final" ] = true
if chatID != "" {
ans [ "chat_id" ] = chatID
}
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , sseMarshalChunk ( sanitizeJSONFloats ( ans ) . ( map [ string ] interface { } ) , chatID ) ) )
2026-07-01 15:52:52 +08:00
}
continue
}
2026-07-02 20:19:50 +08:00
deltaAnswer := ""
2026-07-01 15:52:52 +08:00
if result . StartToThink {
fullAnswer . WriteString ( "<think>" )
} else if result . EndToThink {
fullAnswer . WriteString ( "</think>" )
} else if result . Answer != "" {
fullAnswer . WriteString ( result . Answer )
2026-07-02 20:19:50 +08:00
deltaAnswer = result . Answer
2026-07-01 15:52:52 +08:00
}
if session != nil {
s . appendAssistantToSession ( session , fullAnswer . String ( ) , messageID )
}
2026-07-02 20:19:50 +08:00
ans := s . structureAnswer ( session , deltaAnswer , messageID , sessionID , reference )
ans [ "start_to_think" ] = result . StartToThink
ans [ "end_to_think" ] = result . EndToThink
2026-07-01 15:52:52 +08:00
if chatID != "" {
ans [ "chat_id" ] = chatID
}
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , sseMarshalChunk ( sanitizeJSONFloats ( ans ) . ( map [ string ] interface { } ) , chatID ) ) )
}
2026-06-18 18:07:27 +08:00
}
2026-07-01 15:52:52 +08:00
if legacy && finalLegacyAnswer != nil {
finalLegacyAnswer [ "answer" ] = fullAnswer . String ( )
delete ( finalLegacyAnswer , "start_to_think" )
delete ( finalLegacyAnswer , "end_to_think" )
finalChunk := sseWrapper { Code : 0 , Message : "" , Data : sanitizeJSONFloats ( finalLegacyAnswer ) }
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , marshalJSONWithSpaces ( finalChunk ) ) )
2026-06-18 18:07:27 +08:00
}
2026-07-01 15:52:52 +08:00
wrapper := sseWrapper { Code : 0 , Message : "" , Data : true }
sendOrCancel ( fmt . Sprintf ( "data:%s\n\n" , marshalJSONWithSpaces ( wrapper ) ) )
2026-06-18 18:07:27 +08:00
2026-07-01 15:52:52 +08:00
// Persist session state (matches Python's update_by_id after loop)
if session != nil {
s . updateSessionMessages ( session , s . getSessionMessagesAsSlice ( session ) , reference )
}
} else {
var answer strings . Builder
var finalRef map [ string ] interface { }
for result := range resultChan {
if result . Answer != "" {
answer . WriteString ( result . Answer )
}
if result . Reference != nil {
finalRef = result . Reference
}
}
ans := map [ string ] interface { } {
"answer" : answer . String ( ) ,
"reference" : finalRef ,
"final" : true ,
}
if session != nil {
result := s . structureAnswerWithConv ( session , ans , messageID , sessionID , reference )
if chatID != "" {
result [ "chat_id" ] = chatID
}
s . updateSessionMessages ( session , s . getSessionMessagesAsSlice ( session ) , reference )
return sanitizeJSONFloats ( result ) . ( map [ string ] interface { } ) , nil
}
ans [ "id" ] = messageID
ans [ "session_id" ] = sessionID
if chatID != "" {
ans [ "chat_id" ] = chatID
}
return sanitizeJSONFloats ( ans ) . ( map [ string ] interface { } ) , nil
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
return nil , nil
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
// --- Helpers for ChatCompletions ---
2026-06-09 20:07:45 -07:00
2026-07-01 15:52:52 +08:00
// normalizeCompletionMessages mirrors Python _normalize_completion_messages.
func ( s * ChatSessionService ) normalizeCompletionMessages (
messages [ ] map [ string ] interface { } , question string , files [ ] interface { } ,
) ( requestMessages [ ] map [ string ] interface { } , requestMsg [ ] map [ string ] interface { } , messageID string , err error ) {
2026-03-04 19:17:16 +08:00
if len ( messages ) == 0 {
2026-07-01 15:52:52 +08:00
if question == "" {
return nil , nil , "" , errors . New ( "required argument are missing: messages" )
}
messages = [ ] map [ string ] interface { } { { "role" : "user" , "content" : question } }
if len ( files ) > 0 {
messages [ 0 ] [ "files" ] = files
}
}
requestMessages = make ( [ ] map [ string ] interface { } , len ( messages ) )
for i , m := range messages {
requestMessages [ i ] = make ( map [ string ] interface { } )
for k , v := range m {
requestMessages [ i ] [ k ] = v
}
}
// Filter system and leading assistant messages
requestMsg = make ( [ ] map [ string ] interface { } , 0 , len ( messages ) )
for _ , m := range messages {
role , _ := m [ "role" ] . ( string )
if role == "system" {
continue
}
if role == "assistant" && len ( requestMsg ) == 0 {
continue
}
requestMsg = append ( requestMsg , m )
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
if len ( requestMsg ) == 0 {
return nil , nil , "" , errors . New ( "`messages` must contain a user message." )
}
lastRole , _ := requestMsg [ len ( requestMsg ) - 1 ] [ "role" ] . ( string )
2026-03-04 19:17:16 +08:00
if lastRole != "user" {
2026-07-01 15:52:52 +08:00
return nil , nil , "" , errors . New ( "The last content of this conversation is not from user." )
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
// Generate message ID if missing — matches Python's get_uuid() in _normalize_completion_messages.
lastUserMsg := requestMsg [ len ( requestMsg ) - 1 ]
if id , ok := lastUserMsg [ "id" ] . ( string ) ; ok && id != "" {
messageID = id
} else {
2026-07-07 17:22:08 +08:00
messageID = utility . GenerateToken ( )
2026-07-01 15:52:52 +08:00
lastUserMsg [ "id" ] = messageID
for i := len ( requestMessages ) - 1 ; i >= 0 ; i -- {
if role , _ := requestMessages [ i ] [ "role" ] . ( string ) ; role == "user" {
requestMessages [ i ] [ "id" ] = messageID
break
}
}
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
return requestMessages , requestMsg , messageID , nil
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// checkDialogOwnership checks if the user owns the dialog.
func ( s * ChatSessionService ) checkDialogOwnership ( userID , chatID string ) error {
ok , err := s . ensureOwnedChat ( userID , chatID )
2026-03-04 19:17:16 +08:00
if err != nil {
2026-07-01 15:52:52 +08:00
return err
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
if ! ok {
return errors . New ( "No authorization." )
}
return nil
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// buildDefaultCompletionDialog mirrors Python _build_default_completion_dialog.
func ( s * ChatSessionService ) buildDefaultCompletionDialog ( tenantID string ) * entity . Chat {
return & entity . Chat {
TenantID : tenantID ,
LLMID : "" ,
LLMSetting : entity . JSONMap { } ,
PromptConfig : entity . JSONMap { } ,
KBIDs : entity . JSONSlice { } ,
TopN : 6 ,
TopK : 1024 ,
RerankID : "" ,
SimilarityThreshold : 0.1 ,
VectorSimilarityWeight : 0.3 ,
}
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// createSessionForCompletion mirrors Python _create_session_for_completion.
func ( s * ChatSessionService ) createSessionForCompletion ( chatID string , dialog * entity . Chat , userID string ) ( * entity . ChatSession , error ) {
2026-07-07 17:22:08 +08:00
newID := utility . GenerateUUID ( )
2026-07-01 15:52:52 +08:00
name := "New session"
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
prologue := "Hi! I'm your assistant. What can I do for you?"
if dialog . PromptConfig != nil {
if p , ok := dialog . PromptConfig [ "prologue" ] . ( string ) ; ok && p != "" {
prologue = p
2026-03-04 19:17:16 +08:00
}
}
2026-07-01 15:52:52 +08:00
msg := [ ] map [ string ] interface { } {
{ "role" : "assistant" , "content" : prologue } ,
2026-06-18 18:07:27 +08:00
}
2026-07-01 15:52:52 +08:00
msgJSON , _ := json . Marshal ( msg )
refJSON , _ := json . Marshal ( [ ] interface { } { } )
session := & entity . ChatSession {
ID : newID ,
DialogID : chatID ,
Name : & name ,
Message : msgJSON ,
UserID : & userID ,
Reference : refJSON ,
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
if err := s . chatSessionDAO . Create ( session ) ; err != nil {
return nil , err
}
return session , nil
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// appendSessionMessage appends the last user message to the session's message history.
func ( s * ChatSessionService ) appendSessionMessage ( session * entity . ChatSession , requestMsg [ ] map [ string ] interface { } ) * entity . ChatSession {
msgs := parseMessages ( session . Message )
msgs = append ( msgs , requestMsg [ len ( requestMsg ) - 1 ] )
session . Message , _ = json . Marshal ( msgs )
return session
}
// filterSystemAndLeadingAssistant filters system messages and leading assistant messages from session history.
func ( s * ChatSessionService ) filterSystemAndLeadingAssistant ( session * entity . ChatSession ) [ ] map [ string ] interface { } {
messages := parseMessages ( session . Message )
var result [ ] map [ string ] interface { }
for _ , msg := range messages {
role , _ := msg [ "role" ] . ( string )
if role == "system" {
continue
2026-06-18 18:07:27 +08:00
}
2026-07-01 15:52:52 +08:00
if role == "assistant" && len ( result ) == 0 {
continue
}
result = append ( result , msg )
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
return result
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// appendAssistantToSession appends or updates the assistant message in session.Message.
func ( s * ChatSessionService ) appendAssistantToSession ( session * entity . ChatSession , content string , messageID string ) {
messages := parseMessages ( session . Message )
if len ( messages ) == 0 || s . getLastRole ( messages ) != "assistant" {
messages = append ( messages , map [ string ] interface { } {
2026-06-24 17:34:01 +08:00
"role" : "assistant" ,
2026-07-01 15:52:52 +08:00
"content" : content ,
2026-06-24 17:34:01 +08:00
"created_at" : float64 ( time . Now ( ) . Unix ( ) ) ,
2026-07-01 15:52:52 +08:00
"id" : messageID ,
2026-06-24 17:34:01 +08:00
} )
2026-07-01 15:52:52 +08:00
} else {
lastIdx := len ( messages ) - 1
messages [ lastIdx ] [ "content" ] = content
messages [ lastIdx ] [ "created_at" ] = float64 ( time . Now ( ) . Unix ( ) )
messages [ lastIdx ] [ "id" ] = messageID
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
session . Message , _ = json . Marshal ( messages )
}
2026-03-04 19:17:16 +08:00
2026-07-01 15:52:52 +08:00
// getSessionMessagesAsSlice returns the session's messages as a slice of maps.
func ( s * ChatSessionService ) getSessionMessagesAsSlice ( session * entity . ChatSession ) [ ] map [ string ] interface { } {
if session == nil {
return nil
}
return parseMessages ( session . Message )
}
// sendSSEError sends an error in SSE format through the stream channel.
func ( s * ChatSessionService ) sendSSEError ( streamChan chan <- string , errMsg string ) {
wrapper := sseWrapper {
Code : 500 ,
Message : errMsg ,
Data : map [ string ] interface { } {
"answer" : "**ERROR**: " + errMsg ,
"reference" : [ ] interface { } { } ,
} ,
}
streamChan <- fmt . Sprintf ( "data:%s\n\n" , marshalJSONWithSpaces ( wrapper ) )
2026-03-04 19:17:16 +08:00
}
// Helper methods
2026-03-27 19:25:18 +08:00
func ( s * ChatSessionService ) buildSessionMessages ( session * entity . ChatSession , messages [ ] map [ string ] interface { } ) [ ] map [ string ] interface { } {
2026-06-24 17:34:01 +08:00
prefix := make ( [ ] map [ string ] interface { } , 0 , 1 )
existingMessages := parseMessages ( session . Message )
if len ( existingMessages ) > 0 {
if role , _ := existingMessages [ 0 ] [ "role" ] . ( string ) ; role == "assistant" {
firstIncomingRole := ""
if len ( messages ) > 0 {
firstIncomingRole , _ = messages [ 0 ] [ "role" ] . ( string )
}
if firstIncomingRole != "assistant" {
prologue := make ( map [ string ] interface { } , len ( existingMessages [ 0 ] ) )
for k , v := range existingMessages [ 0 ] {
prologue [ k ] = v
}
prefix = append ( prefix , prologue )
}
}
}
sessionMessages := make ( [ ] map [ string ] interface { } , 0 , len ( prefix ) + len ( messages ) )
sessionMessages = append ( sessionMessages , prefix ... )
for _ , msg := range messages {
cloned := make ( map [ string ] interface { } , len ( msg ) )
2026-03-04 19:17:16 +08:00
for k , v := range msg {
2026-06-24 17:34:01 +08:00
cloned [ k ] = v
2026-03-04 19:17:16 +08:00
}
2026-06-24 17:34:01 +08:00
sessionMessages = append ( sessionMessages , cloned )
2026-03-04 19:17:16 +08:00
}
return sessionMessages
}
2026-03-27 19:25:18 +08:00
func ( s * ChatSessionService ) initializeReference ( session * entity . ChatSession ) [ ] interface { } {
2026-03-04 19:17:16 +08:00
var reference [ ] interface { }
if len ( session . Reference ) > 0 {
json . Unmarshal ( session . Reference , & reference )
}
// Filter out nil entries and append new reference
var filtered [ ] interface { }
for _ , r := range reference {
if r != nil {
filtered = append ( filtered , r )
}
}
filtered = append ( filtered , map [ string ] interface { } {
2026-06-18 18:07:27 +08:00
"chunks" : [ ] map [ string ] interface { } { } ,
2026-03-04 19:17:16 +08:00
"doc_aggs" : [ ] interface { } { } ,
} )
return filtered
}
func ( s * ChatSessionService ) checkTenantLLMAPIKey ( tenantID , modelName string ) ( bool , error ) {
2026-07-01 15:52:52 +08:00
_ , err := NewTenantLLMService ( ) . GetAPIKeyFromInstance ( tenantID , modelName )
if err != nil {
return false , err
}
2026-03-04 19:17:16 +08:00
return true , nil
}
2026-07-01 15:52:52 +08:00
// sseAnswerChunk has deterministic JSON field order matching Python's structure_answer output.
type sseAnswerChunk struct {
2026-07-02 20:19:50 +08:00
Answer string ` json:"answer" `
Reference map [ string ] interface { } ` json:"reference" `
AudioBinary interface { } ` json:"audio_binary" `
Prompt string ` json:"prompt" `
CreatedAt float64 ` json:"created_at" `
Final bool ` json:"final" `
ID string ` json:"id" `
SessionID string ` json:"session_id" `
ChatID string ` json:"chat_id,omitempty" `
StartToThink bool ` json:"start_to_think,omitempty" `
EndToThink bool ` json:"end_to_think,omitempty" `
2026-07-01 15:52:52 +08:00
}
// sseWrapper wraps the SSE response with deterministic field order matching Python:
//
// {"code": 0, "message": "", "data": ...}
type sseWrapper struct {
Code int ` json:"code" `
Message string ` json:"message" `
Data interface { } ` json:"data" `
}
// marshalJSONWithSpaces marshals v to JSON and adds spaces after ':' and ','
// to match Python's json.dumps format.
func marshalJSONWithSpaces ( v interface { } ) string {
data , err := json . Marshal ( v )
if err != nil {
return "{}"
}
return addJSONSpacesOutsideStrings ( data )
}
func addJSONSpacesOutsideStrings ( data [ ] byte ) string {
var b strings . Builder
b . Grow ( len ( data ) + 16 )
inString := false
escaped := false
for _ , c := range data {
b . WriteByte ( c )
if escaped {
escaped = false
continue
}
if inString && c == '\\' {
escaped = true
continue
}
if c == '"' {
inString = ! inString
continue
}
if ! inString && ( c == ':' || c == ',' ) {
b . WriteByte ( ' ' )
}
}
return b . String ( )
}
// sanitizeJSONFloats recursively replaces NaN/Infinity with nil.
// Matches Python's _sanitize_json_floats in chat_api.py.
func sanitizeJSONFloats ( v interface { } ) interface { } {
switch val := v . ( type ) {
case float64 :
if math . IsNaN ( val ) || math . IsInf ( val , 0 ) {
return nil
}
return val
case float32 :
if math . IsNaN ( float64 ( val ) ) || math . IsInf ( float64 ( val ) , 0 ) {
return nil
}
return val
case map [ string ] interface { } :
out := make ( map [ string ] interface { } , len ( val ) )
for k , vv := range val {
out [ k ] = sanitizeJSONFloats ( vv )
}
return out
case [ ] interface { } :
out := make ( [ ] interface { } , len ( val ) )
for i , vv := range val {
out [ i ] = sanitizeJSONFloats ( vv )
}
return out
case [ ] map [ string ] interface { } :
out := make ( [ ] map [ string ] interface { } , len ( val ) )
for i , item := range val {
sanitized , _ := sanitizeJSONFloats ( item ) . ( map [ string ] interface { } )
out [ i ] = sanitized
}
return out
default :
return v
}
}
// sseMarshalChunk converts an answer map to the ordered sseAnswerChunk struct
// and marshals it with Python-compatible JSON formatting (spaces, field order).
func sseMarshalChunk ( ans map [ string ] interface { } , chatID string ) string {
ref , _ := ans [ "reference" ] . ( map [ string ] interface { } )
if ref == nil {
ref = map [ string ] interface { } { "chunks" : [ ] interface { } { } }
}
answer , _ := ans [ "answer" ] . ( string )
prompt , _ := ans [ "prompt" ] . ( string )
id , _ := ans [ "id" ] . ( string )
sessionID , _ := ans [ "session_id" ] . ( string )
createdAt , _ := ans [ "created_at" ] . ( float64 )
final , _ := ans [ "final" ] . ( bool )
2026-07-02 20:19:50 +08:00
startToThink , _ := ans [ "start_to_think" ] . ( bool )
endToThink , _ := ans [ "end_to_think" ] . ( bool )
2026-07-01 15:52:52 +08:00
chunk := sseAnswerChunk {
2026-07-02 20:19:50 +08:00
Answer : answer ,
Reference : ref ,
AudioBinary : ans [ "audio_binary" ] ,
Prompt : prompt ,
CreatedAt : createdAt ,
Final : final ,
ID : id ,
SessionID : sessionID ,
ChatID : chatID ,
StartToThink : startToThink ,
EndToThink : endToThink ,
2026-07-01 15:52:52 +08:00
}
wrapper := sseWrapper { Code : 0 , Message : "" , Data : chunk }
return marshalJSONWithSpaces ( wrapper )
}
2026-03-27 19:25:18 +08:00
func ( s * ChatSessionService ) structureAnswer ( session * entity . ChatSession , answer string , messageID , conversationID string , reference [ ] interface { } ) map [ string ] interface { } {
2026-07-01 15:52:52 +08:00
// Match Python's structure_answer output:
// {"answer", "reference": {"chunks": [...]}, "audio_binary": null, "prompt": "",
// "created_at": ..., "final": false, "id": "...", "session_id": "..."}
refMap := map [ string ] interface { } {
"chunks" : [ ] interface { } { } ,
"doc_aggs" : [ ] interface { } { } ,
}
if len ( reference ) > 0 {
if latest , ok := reference [ len ( reference ) - 1 ] . ( map [ string ] interface { } ) ; ok && latest != nil {
refMap = latest
if _ , ok := refMap [ "chunks" ] ; ! ok {
refMap [ "chunks" ] = [ ] interface { } { }
}
}
}
2026-03-04 19:17:16 +08:00
return map [ string ] interface { } {
2026-07-01 15:52:52 +08:00
"answer" : answer ,
"reference" : refMap ,
"audio_binary" : nil ,
"prompt" : "" ,
"created_at" : float64 ( time . Now ( ) . UnixNano ( ) ) / 1e9 ,
"final" : false ,
"id" : messageID ,
"session_id" : conversationID ,
2026-03-04 19:17:16 +08:00
}
}
2026-03-27 19:25:18 +08:00
func ( s * ChatSessionService ) updateSessionMessages ( session * entity . ChatSession , messages [ ] map [ string ] interface { } , reference [ ] interface { } ) {
2026-07-01 15:52:52 +08:00
messagesJSON , err := json . Marshal ( messages )
if err != nil {
common . Warn ( "updateSessionMessages: failed to marshal messages" , zap . Error ( err ) )
return
}
referenceJSON , err := json . Marshal ( reference )
if err != nil {
common . Warn ( "updateSessionMessages: failed to marshal reference" , zap . Error ( err ) )
return
}
2026-03-04 19:17:16 +08:00
updates := map [ string ] interface { } {
2026-05-25 14:00:08 +08:00
"message" : messagesJSON ,
"reference" : referenceJSON ,
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
if err := s . chatSessionDAO . UpdateByID ( session . ID , updates ) ; err != nil {
common . Warn ( "updateSessionMessages: DAO update failed" , zap . Error ( err ) )
return
}
2026-06-24 17:34:01 +08:00
session . Message = messagesJSON
session . Reference = referenceJSON
2026-03-04 19:17:16 +08:00
}
// structureAnswerWithConv structures the answer with conversation update (like Python's structure_answer)
2026-03-27 19:25:18 +08:00
func ( s * ChatSessionService ) structureAnswerWithConv ( session * entity . ChatSession , ans map [ string ] interface { } , messageID , conversationID string , reference [ ] interface { } ) map [ string ] interface { } {
2026-03-04 19:17:16 +08:00
// Extract reference from answer
ref , _ := ans [ "reference" ] . ( map [ string ] interface { } )
if ref == nil {
ref = map [ string ] interface { } {
2026-06-18 18:07:27 +08:00
"chunks" : [ ] map [ string ] interface { } { } ,
2026-03-04 19:17:16 +08:00
"doc_aggs" : [ ] interface { } { } ,
}
ans [ "reference" ] = ref
}
// Format chunks
chunkList := s . chunksFormat ( ref )
ref [ "chunks" ] = chunkList
// Add message ID and session ID
ans [ "id" ] = messageID
ans [ "session_id" ] = conversationID
// Update session message
content , _ := ans [ "answer" ] . ( string )
if ans [ "start_to_think" ] != nil {
content = "<think>"
} else if ans [ "end_to_think" ] != nil {
content = "</think>"
}
2026-06-24 17:34:01 +08:00
// Parse existing messages. Keep backward compatibility with wrapped legacy rows.
messages := parseMessages ( session . Message )
2026-03-04 19:17:16 +08:00
// Update or append assistant message
if len ( messages ) == 0 || s . getLastRole ( messages ) != "assistant" {
messages = append ( messages , map [ string ] interface { } {
"role" : "assistant" ,
"content" : content ,
"created_at" : float64 ( time . Now ( ) . Unix ( ) ) ,
"id" : messageID ,
} )
} else {
lastIdx := len ( messages ) - 1
2026-06-24 17:34:01 +08:00
lastMsg := messages [ lastIdx ]
if ans [ "final" ] == true && ans [ "answer" ] != nil {
lastMsg [ "content" ] = ans [ "answer" ]
} else {
existing , _ := lastMsg [ "content" ] . ( string )
lastMsg [ "content" ] = existing + content
2026-03-04 19:17:16 +08:00
}
2026-06-24 17:34:01 +08:00
lastMsg [ "created_at" ] = float64 ( time . Now ( ) . Unix ( ) )
lastMsg [ "id" ] = messageID
messages [ lastIdx ] = lastMsg
2026-03-04 19:17:16 +08:00
}
2026-06-24 17:34:01 +08:00
session . Message , _ = json . Marshal ( messages )
2026-03-04 19:17:16 +08:00
// Update reference
if len ( reference ) > 0 {
reference [ len ( reference ) - 1 ] = ref
}
return ans
}
// getLastRole gets the role of the last message
2026-06-24 17:34:01 +08:00
func ( s * ChatSessionService ) getLastRole ( messages [ ] map [ string ] interface { } ) string {
2026-03-04 19:17:16 +08:00
if len ( messages ) == 0 {
return ""
}
2026-06-24 17:34:01 +08:00
role , _ := messages [ len ( messages ) - 1 ] [ "role" ] . ( string )
return role
2026-03-04 19:17:16 +08:00
}
2026-07-01 15:52:52 +08:00
// chunksFormat normalizes chunk fields to a canonical schema (matching
// formatChunks in openai_chat.go and Python's chunks_format), returning
// []map[string]interface{} for JSON serialization.
2026-06-18 18:07:27 +08:00
func ( s * ChatSessionService ) chunksFormat ( reference map [ string ] interface { } ) [ ] map [ string ] interface { } {
2026-07-01 15:52:52 +08:00
raw , ok := reference [ "chunks" ] . ( [ ] map [ string ] interface { } )
if ! ok {
// Coerce []interface{} → []map[string]interface{}
if ifaces , ok2 := reference [ "chunks" ] . ( [ ] interface { } ) ; ok2 {
raw = make ( [ ] map [ string ] interface { } , 0 , len ( ifaces ) )
for _ , item := range ifaces {
if m , ok3 := item . ( map [ string ] interface { } ) ; ok3 {
raw = append ( raw , m )
}
2026-04-29 17:04:22 +08:00
}
2026-04-29 14:44:12 +08:00
}
2026-07-01 15:52:52 +08:00
}
if len ( raw ) == 0 {
2026-06-18 18:07:27 +08:00
return [ ] map [ string ] interface { } { }
2026-04-29 14:44:12 +08:00
}
2026-07-01 15:52:52 +08:00
out := make ( [ ] map [ string ] interface { } , 0 , len ( raw ) )
for _ , chunk := range raw {
out = append ( out , map [ string ] interface { } {
"id" : getValue ( chunk , "chunk_id" , "id" ) ,
"content" : getValue ( chunk , "content_with_weight" , "content" ) ,
"document_id" : getValue ( chunk , "doc_id" , "document_id" ) ,
"document_name" : getValue ( chunk , "docnm_kwd" , "document_name" ) ,
"dataset_id" : getValue ( chunk , "kb_id" , "dataset_id" ) ,
"image_id" : getValue ( chunk , "image_id" , "img_id" ) ,
"positions" : getValue ( chunk , "positions" , "position_int" ) ,
"url" : chunk [ "url" ] ,
"similarity" : sanitizeJSONFloats ( chunk [ "similarity" ] ) ,
"vector_similarity" : sanitizeJSONFloats ( chunk [ "vector_similarity" ] ) ,
"term_similarity" : sanitizeJSONFloats ( chunk [ "term_similarity" ] ) ,
"row_id" : chunk [ "row_id" ] ,
"doc_type" : getValue ( chunk , "doc_type_kwd" , "doc_type" ) ,
"document_metadata" : chunk [ "document_metadata" ] ,
} )
}
return out
2026-04-29 14:44:12 +08:00
}