Refactor: move chat message/reference to dedicated tables (#19577)

This commit is contained in:
Wang Qi
2026-09-15 15:52:12 +08:00
committed by GitHub
parent 3a04af1dcd
commit fe501ebb04
47 changed files with 1900 additions and 1065 deletions
+9
View File
@@ -56,6 +56,7 @@ import (
"syscall"
"time"
"github.com/cloudwego/eino/schema"
"github.com/gin-gonic/gin"
"go.uber.org/zap"
@@ -1019,6 +1020,14 @@ func startServer(ctx context.Context) {
// uses min(chat_mdl.max_length, _EVIDENCE_BUDGET_TOKENS=8000),
// which EvidenceMaxTokens<=0 reproduces).
}
for _, message := range req.Messages {
content, err := service.NormalizeOpenAIMessageContent(message["content"])
if err != nil {
return service.HarnessResult{}, err
}
role, _ := message["role"].(string)
deps.Messages = append(deps.Messages, schema.Message{Role: schema.RoleType(role), Content: content})
}
// Diagnose WHY compiled expansion is disabled: NewCompiledExpander
// returns nil for three reasons (store==nil / no datasets / no tenant)
// and RAGTools.Expand==nil silences the whole channel with no other
+24
View File
@@ -623,6 +623,18 @@ func (s *Service) DeleteUser(ctx context.Context, username string) (*DeleteUserR
// 9. Delete chat sessions
if len(dialogIDs) > 0 {
var sessionIDs []string
if pluckErr := tx.Model(&entity.ChatSession{}).Where("dialog_id IN ?", dialogIDs).Pluck("id", &sessionIDs); pluckErr.Error != nil {
common.Warn("failed to get chat session IDs", zap.Error(pluckErr.Error))
}
if len(sessionIDs) > 0 {
if delErr := tx.Table("conversation_message").Where("conversation_id IN ?", sessionIDs).Delete(map[string]interface{}{}); delErr.Error != nil {
common.Warn("failed to delete conversation messages", zap.Error(delErr.Error))
}
if delErr := tx.Table("conversation_reference").Where("conversation_id IN ?", sessionIDs).Delete(map[string]interface{}{}); delErr.Error != nil {
common.Warn("failed to delete conversation references", zap.Error(delErr.Error))
}
}
if delErr := tx.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.ChatSession{}); delErr.Error != nil {
common.Warn("failed to delete chat sessions", zap.Error(delErr.Error))
}
@@ -640,6 +652,18 @@ func (s *Service) DeleteUser(ctx context.Context, username string) (*DeleteUserR
// 12. Delete API4Conversations
if len(dialogIDs) > 0 {
var sessionIDs []string
if pluckErr := tx.Model(&entity.API4Conversation{}).Where("dialog_id IN ?", dialogIDs).Pluck("id", &sessionIDs); pluckErr.Error != nil {
common.Warn("failed to get API conversation IDs", zap.Error(pluckErr.Error))
}
if len(sessionIDs) > 0 {
if delErr := tx.Table("api_4_conversation_message").Where("conversation_id IN ?", sessionIDs).Delete(map[string]interface{}{}); delErr.Error != nil {
common.Warn("failed to delete API conversation messages", zap.Error(delErr.Error))
}
if delErr := tx.Table("api_4_conversation_reference").Where("conversation_id IN ?", sessionIDs).Delete(map[string]interface{}{}); delErr.Error != nil {
common.Warn("failed to delete API conversation references", zap.Error(delErr.Error))
}
}
if delErr := tx.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.API4Conversation{}); delErr.Error != nil {
common.Warn("failed to delete API4Conversations", zap.Error(delErr.Error))
}
+5 -2
View File
@@ -273,8 +273,11 @@ func (r *Runner) Run(
return out
}
// Generate the message identifier the RunFunc and SSE envelope need.
messageID := utility.GenerateToken()
// Reuse a persisted question's identifier for the RunFunc and SSE envelope.
messageID, _ := root["__message_id__"].(string)
if messageID == "" {
messageID = utility.GenerateToken()
}
// Inject the output channel + metadata so the RunFunc can emit
// events during execution (workflow_started, node_started,
+1 -2
View File
@@ -1061,7 +1061,7 @@ Syntax:
[system "..."] [history "..."] [history_delimiter "<char>"]
[temperature <float>] [max_tokens <int>] [stream <bool>]
[top_p <float>] [frequency_penalty <float>] [presence_penalty <float>]
[pass_all_history <bool>] [legacy <bool>] ;
[legacy <bool>] ;
Required positional:
'question' the user question
@@ -1079,7 +1079,6 @@ Named options (any order; all optional with defaults):
top_p <float> 0..1
frequency_penalty <float> -2..2
presence_penalty <float> -2..2
pass_all_history <bool> pass all history messages
legacy <bool> use legacy SSE format
Defaults:
-3
View File
@@ -4064,9 +4064,6 @@ func buildChatCompletionsRequestBody(cmd *Command) (map[string]interface{}, erro
}
// Optional flags — only emit when explicitly set
if isSet(cmd, "pass_all_history") && cmd.Params["pass_all_history"].(bool) {
body["pass_all_history_messages"] = true
}
if isSet(cmd, "legacy") && cmd.Params["legacy"].(bool) {
body["legacy"] = true
}
+3 -3
View File
@@ -3916,7 +3916,7 @@ func (p *Parser) parseChatCompletionsBody() (*Command, error) {
cmd.Params["max_tokens"] = v
p.nextToken()
markSet(cmd, "max_tokens")
case "stream", "pass_all_history", "legacy":
case "stream", "legacy":
v, err := p.parseBool()
if err != nil {
return fmt.Errorf("CHAT COMPLETIONS %s: expected true|false, got %s", name, p.curToken.Value)
@@ -3938,7 +3938,7 @@ func (p *Parser) parseChatCompletionsBody() (*Command, error) {
cmd.Params["history_delimiter"] = v
p.nextToken()
default:
return fmt.Errorf("CHAT COMPLETIONS: unknown option %q (valid: chat_id, session, llm, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, pass_all_history, legacy)", name)
return fmt.Errorf("CHAT COMPLETIONS: unknown option %q (valid: chat_id, session, llm, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, legacy)", name)
}
return nil
}
@@ -3965,7 +3965,7 @@ optionsLoop:
default:
if !isKeyword(p.curToken.Type) {
return nil, fmt.Errorf("CHAT COMPLETIONS: unexpected token %q in option list (valid options: chat_id, session, llm, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, pass_all_history, legacy)", p.curToken.Value)
return nil, fmt.Errorf("CHAT COMPLETIONS: unexpected token %q in option list (valid options: chat_id, session, llm, system, history, history_delimiter, temperature, max_tokens, stream, top_p, frequency_penalty, presence_penalty, legacy)", p.curToken.Value)
}
name := p.curToken.Value
p.nextToken()
+79 -21
View File
@@ -20,8 +20,10 @@ import (
"context"
"errors"
"ragflow/internal/entity"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
)
// API4ConversationDAO API for conversation data access object
@@ -45,29 +47,46 @@ type ConversationStatsRow struct {
// Create inserts a new api_4_conversation row. The caller is responsible
// for setting ID, DialogID, UserID and the BaseModel time fields; the
// DAO does not assign defaults because session creation paths in the
// Python agent API generate an uuid + tenant timestamp and rely on the
// round-trip shape being byte-identical.
// DAO does not assign defaults because callers own session IDs and timestamps.
func (dao *API4ConversationDAO) Create(ctx context.Context, db *gorm.DB, conv *entity.API4Conversation) error {
if conv == nil {
return errors.New("api4 conversation: nil row")
}
return db.WithContext(ctx).Create(conv).Error
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(conv).Error; err != nil {
return err
}
if err := createHistory(ctx, tx, apiConversationMessageTable, "message", conv.ID, conv.Message); err != nil {
return err
}
return createHistory(ctx, tx, apiConversationReferenceTable, "reference", conv.ID, conv.Reference)
})
}
// Update writes back an existing api_4_conversation row. The bot
// completion path calls this with the updated Message JSON after each
// turn so multi-turn chatbot sessions carry prior history into the next
// LLM call. Matches the Python conversation_service.update pattern at
// api/db/services/conversation_service.py:236 (async_iframe_completion).
func (dao *API4ConversationDAO) Update(ctx context.Context, db *gorm.DB, conv *entity.API4Conversation) error {
if conv == nil {
return errors.New("api4 conversation: nil row")
// UpdateHistory locks the parent before writing only the addressed child rows.
func (dao *API4ConversationDAO) UpdateHistory(ctx context.Context, db *gorm.DB, sessionID, agentID, userID string, updates map[string]interface{}, history ConversationHistoryUpdate) error {
if updates == nil {
updates = make(map[string]interface{})
}
if conv.ID == "" {
return errors.New("api4 conversation: empty id")
for key := range updates {
switch key {
case "message", "Message", "messages", "Messages", "reference", "Reference":
return errors.New("does not support")
}
}
return db.WithContext(ctx).Save(conv).Error
now := time.Now().Local()
updates["update_time"], updates["update_date"] = now.UnixMilli(), now.Truncate(time.Second)
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
query := tx.Session(&gorm.Session{SkipHooks: true}).Model(&entity.API4Conversation{}).Where("id = ? AND dialog_id = ? AND user_id = ?", sessionID, agentID, userID)
var parent entity.API4Conversation
if err := query.Session(&gorm.Session{}).Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Take(&parent).Error; err != nil {
return err
}
if err := query.Updates(updates).Error; err != nil {
return err
}
return updateConversationHistory(ctx, tx, apiConversationMessageTable, apiConversationReferenceTable, sessionID, history)
})
}
// Stats returns daily conversation aggregates for a tenant.
@@ -100,8 +119,20 @@ func (dao *API4ConversationDAO) Stats(ctx context.Context, db *gorm.DB, tenantID
}
func (dao *API4ConversationDAO) GetBySessionID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (*entity.API4Conversation, error) {
result, err := dao.GetMetadataBySessionID(ctx, db, sessionID, agentID)
if err != nil || result == nil {
return result, err
}
if err := hydrateAPIConversations(ctx, db, []*entity.API4Conversation{result}); err != nil {
return nil, err
}
return result, nil
}
// GetMetadataBySessionID avoids history hydration for authorization and DSL reads.
func (dao *API4ConversationDAO) GetMetadataBySessionID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (*entity.API4Conversation, error) {
var result entity.API4Conversation
tx := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, agentID).Find(&result)
tx := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("id = ? AND dialog_id = ?", sessionID, agentID).Find(&result)
if tx.Error != nil {
return nil, tx.Error
}
@@ -115,13 +146,16 @@ func (dao *API4ConversationDAO) GetBySessionID(ctx context.Context, db *gorm.DB,
// agent. It is used when the session itself is the authorization resource.
func (dao *API4ConversationDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.API4Conversation, error) {
var result entity.API4Conversation
tx := db.WithContext(ctx).Where("id = ?", id).Find(&result)
tx := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("id = ?", id).Find(&result)
if tx.Error != nil {
return nil, tx.Error
}
if tx.RowsAffected == 0 {
return nil, nil
}
if err := hydrateAPIConversations(ctx, db, []*entity.API4Conversation{&result}); err != nil {
return nil, err
}
return &result, nil
}
@@ -134,8 +168,20 @@ func (dao *API4ConversationDAO) ListIDsByAgentID(ctx context.Context, db *gorm.D
// DeleteBySessionIDAndAgentID deletes API4Conversations by sessionID and agentID
func (dao *API4ConversationDAO) DeleteBySessionIDAndAgentID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (int64, error) {
result := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, agentID).Delete(&entity.API4Conversation{})
return result.RowsAffected, result.Error
var rowsAffected int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var ids []string
if err := tx.Model(&entity.API4Conversation{}).Clauses(clause.Locking{Strength: "UPDATE"}).Where("id = ? AND dialog_id = ?", sessionID, agentID).Order("id").Pluck("id", &ids).Error; err != nil {
return err
}
if err := deleteHistory(ctx, tx, []string{apiConversationMessageTable, apiConversationReferenceTable}, ids); err != nil {
return err
}
result := tx.Where("id = ? AND dialog_id = ?", sessionID, agentID).Delete(&entity.API4Conversation{})
rowsAffected = result.RowsAffected
return result.Error
})
return rowsAffected, err
}
// DeleteByDialogIDs deletes API4Conversations by dialog IDs (hard delete)
@@ -143,6 +189,18 @@ func (dao *API4ConversationDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.
if len(dialogIDs) == 0 {
return 0, nil
}
result := db.WithContext(ctx).Where("dialog_id IN ?", dialogIDs).Delete(&entity.API4Conversation{})
return result.RowsAffected, result.Error
var rowsAffected int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var ids []string
if err := tx.Model(&entity.API4Conversation{}).Clauses(clause.Locking{Strength: "UPDATE"}).Where("dialog_id IN ?", dialogIDs).Order("id").Pluck("id", &ids).Error; err != nil {
return err
}
if err := deleteHistory(ctx, tx, []string{apiConversationMessageTable, apiConversationReferenceTable}, ids); err != nil {
return err
}
result := tx.Where("dialog_id IN ?", dialogIDs).Delete(&entity.API4Conversation{})
rowsAffected = result.RowsAffected
return result.Error
})
return rowsAffected, err
}
+3 -3
View File
@@ -37,7 +37,7 @@ func setupAPI4ConversationTestDB(t *testing.T) *gorm.DB {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.API4Conversation{}); err != nil {
if err := db.AutoMigrate(&entity.API4Conversation{}, &entity.API4ConversationMessage{}, &entity.API4ConversationReference{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
@@ -47,13 +47,13 @@ func setupAPI4ConversationTestDB(t *testing.T) *gorm.DB {
func createAPI4ConversationForDAOTest(t *testing.T, id, agentID string) {
t.Helper()
ctx := t.Context()
if err := DB.WithContext(ctx).Create(&entity.API4Conversation{
if err := NewAPI4ConversationDAO().Create(ctx, DB, &entity.API4Conversation{
ID: id,
DialogID: agentID,
UserID: "user-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("failed to create api conversation %s: %v", id, err)
}
}
+93 -36
View File
@@ -18,11 +18,12 @@ package dao
import (
"context"
"strconv"
"errors"
"strings"
"time"
"gorm.io/gorm"
"gorm.io/gorm/clause"
"ragflow/internal/common"
"ragflow/internal/entity"
@@ -41,6 +42,7 @@ type ListAgentSessionsParams struct {
UserID string
TenantID string
IncludeDSL bool
NoHistory bool
Keywords string
FromDate *time.Time
ToDate *time.Time
@@ -55,26 +57,40 @@ func NewChatSessionDAO() *ChatSessionDAO {
// GetByID gets chat session by ID
func (dao *ChatSessionDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.ChatSession, error) {
var conv entity.ChatSession
err := db.WithContext(ctx).Where("id = ?", id).First(&conv).Error
err := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("id = ?", id).First(&conv).Error
if err != nil {
return nil, err
}
if err = hydrateChatSessions(ctx, db, []*entity.ChatSession{&conv}); err != nil {
return nil, err
}
return &conv, nil
}
// GetBySessionIDAndChatID gets a chat session by session ID and chat ID.
func (dao *ChatSessionDAO) GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string) (*entity.ChatSession, error) {
var conv entity.ChatSession
err := db.WithContext(ctx).Where("id = ? AND dialog_id = ?", sessionID, chatID).First(&conv).Error
err := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("id = ? AND dialog_id = ?", sessionID, chatID).First(&conv).Error
if err != nil {
return nil, err
}
if err = hydrateChatSessions(ctx, db, []*entity.ChatSession{&conv}); err != nil {
return nil, err
}
return &conv, nil
}
// Create creates a new chat session
func (dao *ChatSessionDAO) Create(ctx context.Context, db *gorm.DB, conv *entity.ChatSession) error {
return db.WithContext(ctx).Create(conv).Error
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
if err := tx.Create(conv).Error; err != nil {
return err
}
if err := createHistory(ctx, tx, conversationMessageTable, "message", conv.ID, conv.Message); err != nil {
return err
}
return createHistory(ctx, tx, conversationReferenceTable, "reference", conv.ID, conv.Reference)
})
}
// UpdateByID updates a chat session by ID
@@ -83,35 +99,58 @@ func (dao *ChatSessionDAO) UpdateByID(ctx context.Context, db *gorm.DB, id strin
updates = make(map[string]interface{})
}
historyUpdate, targeted := updates["history_update"].(ConversationHistoryUpdate)
delete(updates, "history_update")
for key := range updates {
switch key {
case "message", "Message", "messages", "Messages", "reference", "Reference":
return errors.New("does not support")
}
}
now := time.Now().Local()
updates["update_time"] = now.UnixMilli()
updates["update_date"] = now.Truncate(time.Second)
result := db.WithContext(ctx).Session(&gorm.Session{SkipHooks: true}).Model(&entity.ChatSession{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
if result.RowsAffected == 0 {
var count int64
if err := db.WithContext(ctx).Model(&entity.ChatSession{}).Where("id = ?", id).Count(&count).Error; err != nil {
return err
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
result := tx.Session(&gorm.Session{SkipHooks: true}).Model(&entity.ChatSession{}).Where("id = ?", id).Updates(updates)
if result.Error != nil {
return result.Error
}
if count == 0 {
return gorm.ErrRecordNotFound
if result.RowsAffected == 0 {
var count int64
if err := tx.Model(&entity.ChatSession{}).Where("id = ?", id).Count(&count).Error; err != nil {
return err
}
if count == 0 {
return gorm.ErrRecordNotFound
}
}
}
return nil
if targeted {
return updateConversationHistory(ctx, tx, conversationMessageTable, conversationReferenceTable, id, historyUpdate)
}
return nil
})
}
// DeleteByID deletes a chat session by ID (hard delete)
func (dao *ChatSessionDAO) DeleteByID(ctx context.Context, db *gorm.DB, id string) error {
return db.WithContext(ctx).Where("id = ?", id).Delete(&entity.ChatSession{}).Error
return db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var parent entity.ChatSession
if err := tx.Clauses(clause.Locking{Strength: "UPDATE"}).Select("id").Where("id = ?", id).Take(&parent).Error; err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err := deleteHistory(ctx, tx, []string{conversationMessageTable, conversationReferenceTable}, []string{id}); err != nil {
return err
}
return tx.Where("id = ?", id).Delete(&entity.ChatSession{}).Error
})
}
// ListByChatID lists chat sessions by chat ID
func (dao *ChatSessionDAO) ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name, orderby string, desc bool, page, pageSize int) ([]*entity.ChatSession, error) {
func (dao *ChatSessionDAO) ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name, orderby string, desc bool, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error) {
var chatSessions []*entity.ChatSession
query := db.WithContext(ctx).Where("dialog_id = ?", chatID)
query := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Where("dialog_id = ?", chatID)
if sessionID != "" {
query = query.Where("id = ?", sessionID)
}
@@ -133,7 +172,15 @@ func (dao *ChatSessionDAO) ListByChatID(ctx context.Context, db *gorm.DB, chatID
query = query.Offset((page - 1) * pageSize).Limit(pageSize)
}
err := query.Find(&chatSessions).Error
return chatSessions, err
if err != nil {
return nil, err
}
if len(includeHistory) == 0 || includeHistory[0] {
if err = hydrateChatSessions(ctx, db, chatSessions); err != nil {
return nil, err
}
}
return chatSessions, nil
}
// CheckDialogExists checks if a dialog exists with given tenant_id and dialog_id
@@ -163,8 +210,20 @@ func (dao *ChatSessionDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.DB, d
if len(dialogIDs) == 0 {
return 0, nil
}
result := db.WithContext(ctx).Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.ChatSession{})
return result.RowsAffected, result.Error
var rowsAffected int64
err := db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
var ids []string
if err := tx.Model(&entity.ChatSession{}).Clauses(clause.Locking{Strength: "UPDATE"}).Where("dialog_id IN ?", dialogIDs).Order("id").Pluck("id", &ids).Error; err != nil {
return err
}
if err := deleteHistory(ctx, tx, []string{conversationMessageTable, conversationReferenceTable}, ids); err != nil {
return err
}
result := tx.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.ChatSession{})
rowsAffected = result.RowsAffected
return result.Error
})
return rowsAffected, err
}
func (dao *ChatSessionDAO) ListAgentSessionNames(ctx context.Context, db *gorm.DB, agentID, expUserID string) ([]map[string]interface{}, error) {
@@ -205,7 +264,7 @@ func normalizeAgentSessionOrderBy(orderBy string) string {
}
func (dao *ChatSessionDAO) ListAgentSessions(ctx context.Context, db *gorm.DB, params ListAgentSessionsParams) (int64, []*entity.API4Conversation, error) {
query := db.WithContext(ctx).Model(&entity.API4Conversation{}).Where("dialog_id = ?", params.AgentID)
query := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Model(&entity.API4Conversation{}).Where("dialog_id = ?", params.AgentID)
if !params.IncludeDSL {
query = query.Omit("dsl")
}
@@ -220,19 +279,9 @@ func (dao *ChatSessionDAO) ListAgentSessions(ctx context.Context, db *gorm.DB, p
if params.Keywords != "" {
keywords := strings.ToLower(params.Keywords)
escapedKeywords := strings.Trim(strconv.QuoteToASCII(keywords), `"`)
keywordPattern := "%" + keywords + "%"
if escapedKeywords == keywords {
query = query.Where("(LOWER(id) LIKE ? OR LOWER(name) LIKE ? OR LOWER(message) LIKE ?)", keywordPattern, keywordPattern, keywordPattern)
} else {
query = query.Where(
"(LOWER(id) LIKE ? OR LOWER(name) LIKE ? OR LOWER(message) LIKE ? OR LOWER(message) LIKE ?)",
keywordPattern,
keywordPattern,
keywordPattern,
"%"+escapedKeywords+"%",
)
}
messageMatch := "EXISTS (SELECT 1 FROM " + apiConversationMessageTable + " AS cm WHERE cm.conversation_id = api_4_conversation.id AND LOWER(cm.content) LIKE ?)"
query = query.Where("(LOWER(id) LIKE ? OR LOWER(name) LIKE ? OR "+messageMatch+")", keywordPattern, keywordPattern, keywordPattern)
}
dateColumn := "create_date"
@@ -280,6 +329,14 @@ func (dao *ChatSessionDAO) ListAgentSessions(ctx context.Context, db *gorm.DB, p
Offset((page - 1) * pageSize).
Limit(pageSize).
Find(&sessions).Error
if err != nil {
return 0, nil, err
}
if !params.NoHistory {
if err = hydrateAPIConversations(ctx, db, sessions); err != nil {
return 0, nil, err
}
}
return total, sessions, err
return total, sessions, nil
}
+5 -4
View File
@@ -38,7 +38,7 @@ func setupChatSessionDAOTestDB(t *testing.T) *gorm.DB {
t.Fatalf("failed to open sqlite: %v", err)
}
if err := db.AutoMigrate(&entity.API4Conversation{}, &entity.ChatSession{}); err != nil {
if err := db.AutoMigrate(&entity.API4Conversation{}, &entity.API4ConversationMessage{}, &entity.API4ConversationReference{}, &entity.ChatSession{}, &entity.ConversationMessage{}, &entity.ConversationReference{}); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
@@ -62,7 +62,7 @@ func createAgentSessionForDAOTest(t *testing.T, db *gorm.DB, id, agentID, userID
UpdateDate: &updateDate,
},
}
if err := db.Create(session).Error; err != nil {
if err := NewAPI4ConversationDAO().Create(t.Context(), db, session); err != nil {
t.Fatalf("failed to create session %s: %v", id, err)
}
}
@@ -85,7 +85,7 @@ func createNamedAgentSessionForDAOTest(t *testing.T, db *gorm.DB, id, agentID, u
UpdateDate: &updateDate,
},
}
if err := db.Create(session).Error; err != nil {
if err := NewAPI4ConversationDAO().Create(t.Context(), db, session); err != nil {
t.Fatalf("failed to create session %s: %v", id, err)
}
}
@@ -107,7 +107,7 @@ func createChatSessionForDAOTest(t *testing.T, db *gorm.DB, id, chatID, name str
UpdateDate: &updateDate,
},
}
if err := db.Create(session).Error; err != nil {
if err := NewChatSessionDAO().Create(t.Context(), db, session); err != nil {
t.Fatalf("failed to create chat session %s: %v", id, err)
}
}
@@ -235,6 +235,7 @@ func TestChatSessionDAOListAgentSessionsSearchesIDNameAndMessage(t *testing.T) {
createNamedAgentSessionForDAOTest(t, db, "release-session-id", "agent-1", "user-1", "plain", json.RawMessage(`[{"content":"ordinary"}]`), 1000)
createNamedAgentSessionForDAOTest(t, db, "session-title", "agent-1", "user-1", "Release Notes", json.RawMessage(`[{"content":"ordinary"}]`), 2000)
createNamedAgentSessionForDAOTest(t, db, "session-message", "agent-1", "user-1", "plain", json.RawMessage(`[{"content":"release details"}]`), 3000)
createNamedAgentSessionForDAOTest(t, db, "session-metadata", "agent-1", "user-1", "plain", json.RawMessage(`[{"content":"ordinary","feedback":"release","attachment":"release"}]`), 3500)
createNamedAgentSessionForDAOTest(t, db, "other-agent-release", "agent-2", "user-1", "Release Notes", json.RawMessage(`[{"content":"release details"}]`), 4000)
ctx := t.Context()
+449
View File
@@ -0,0 +1,449 @@
//
// 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 dao
import (
"bytes"
"context"
"encoding/json"
"errors"
"fmt"
"sort"
"strconv"
"gorm.io/gorm"
"ragflow/internal/entity"
)
const (
conversationMessageTable = "conversation_message"
conversationReferenceTable = "conversation_reference"
apiConversationMessageTable = "api_4_conversation_message"
apiConversationReferenceTable = "api_4_conversation_reference"
conversationHistoryIDColumn = "conversation_id"
conversationHistoryOrderColumn = "position"
)
type conversationHistoryRow struct {
ConversationID string `gorm:"column:conversation_id"`
Position int `gorm:"column:position"`
Payload string `gorm:"column:payload"`
MessagePos *int `gorm:"column:message_position"`
}
// ConversationHistoryUpdate changes only the addressed turn, never synchronizing
// historical payloads. QuestionID places an answer in its question's reserved slot.
type ConversationHistoryUpdate struct {
Message map[string]interface{}
QuestionID string
Reference map[string]interface{}
AppendReference bool
DeleteMessageID string
FeedbackMessageID string
Feedback map[string]interface{}
}
func historyReferenceQuery(ctx context.Context, db *gorm.DB, messageTable, referenceTable, conversationID string, messagePosition int) (*gorm.DB, error) {
query := db.WithContext(ctx).Table(referenceTable).Where("conversation_id = ?", conversationID)
var row conversationHistoryRow
err := query.Session(&gorm.Session{}).Select("position").Where("position = ? AND message_position = ?", messagePosition, messagePosition).Take(&row).Error
if err == nil {
return query.Where("position = ?", row.Position), nil
}
if !errors.Is(err, gorm.ErrRecordNotFound) {
return nil, err
}
// Histories explicitly supplied as arrays have no position association.
// Resolve their remaining references by assistant order, not array pairs.
var ordinal int64
err = db.WithContext(ctx).Table(messageTable).
Where("conversation_id = ? AND role = ? AND position < ? AND position >= (SELECT MIN(position) FROM "+messageTable+" WHERE conversation_id = ? AND role = ?)", conversationID, "assistant", messagePosition, conversationID, "user").Count(&ordinal).Error
if err != nil {
return nil, err
}
err = query.Session(&gorm.Session{}).Select("position").Where("message_position IS NULL").Order("position").Offset(int(ordinal)).Take(&row).Error
if err != nil {
return nil, err
}
return query.Where("position = ?", row.Position), nil
}
func updateConversationHistory(ctx context.Context, db *gorm.DB, messageTable, referenceTable, conversationID string, update ConversationHistoryUpdate) error {
if update.DeleteMessageID != "" {
var question entity.ConversationMessage
query := db.WithContext(ctx).Table(messageTable).Where("conversation_id = ? AND message_id = ?", conversationID, update.DeleteMessageID)
if err := query.Session(&gorm.Session{}).Select("position").Where("role = ?", "user").Order("position").Take(&question).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
positions := []int{question.Position}
var answer entity.ConversationMessage
err := query.Session(&gorm.Session{}).Select("position").Where("role = ? AND position = ?", "assistant", question.Position+1).Take(&answer).Error
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
positions = append(positions, answer.Position)
refQuery, err := historyReferenceQuery(ctx, db, messageTable, referenceTable, conversationID, answer.Position)
if err != nil && !errors.Is(err, gorm.ErrRecordNotFound) {
return err
}
if err == nil {
if err := refQuery.Delete(map[string]interface{}{}).Error; err != nil {
return err
}
}
}
return query.Where("position IN ?", positions).Delete(map[string]interface{}{}).Error
}
if update.FeedbackMessageID != "" {
query := db.WithContext(ctx).Table(messageTable).Where("conversation_id = ? AND message_id = ? AND role = ?", conversationID, update.FeedbackMessageID, "assistant")
var message entity.ConversationMessage
if err := query.Session(&gorm.Session{}).Select("position", "metadata").Order("position").Take(&message).Error; err != nil {
if errors.Is(err, gorm.ErrRecordNotFound) {
return nil
}
return err
}
metadata := make(map[string]json.RawMessage)
if message.Metadata != "" {
if err := json.Unmarshal([]byte(message.Metadata), &metadata); err != nil {
return err
}
}
if metadata == nil {
metadata = make(map[string]json.RawMessage)
}
delete(metadata, "thumbup")
if feedback, ok := update.Feedback["feedback"]; ok {
delete(metadata, "feedback")
if _, isString := feedback.(string); feedback != nil && !isString {
raw, err := json.Marshal(feedback)
if err != nil {
return err
}
metadata["feedback"] = raw
update.Feedback["feedback"] = nil
}
}
raw, err := json.Marshal(metadata)
if err != nil {
return err
}
update.Feedback["metadata"] = string(raw)
return query.Where("position = ?", message.Position).Updates(update.Feedback).Error
}
if update.Message == nil && !update.AppendReference {
return nil
}
var fields entity.ConversationMessageFields
if update.Message != nil {
raw, err := json.Marshal(update.Message)
if err != nil {
return err
}
fields, err = flattenMessage(raw)
if err != nil {
return err
}
}
position := 0
if update.QuestionID != "" {
var question entity.ConversationMessage
if err := db.WithContext(ctx).Table(messageTable).Select("position").Where("conversation_id = ? AND message_id = ? AND role = ?", conversationID, update.QuestionID, "user").Order("position DESC").Take(&question).Error; err != nil {
return err
}
position = question.Position + 1
} else {
var lastMessage, lastReference int
if err := db.WithContext(ctx).Table(messageTable).Select("COALESCE(MAX(position), -2)").Where("conversation_id = ?", conversationID).Scan(&lastMessage).Error; err != nil {
return err
}
if err := db.WithContext(ctx).Table(referenceTable).Select("COALESCE(MAX(position), -2)").Where("conversation_id = ?", conversationID).Scan(&lastReference).Error; err != nil {
return err
}
position = max(lastMessage, lastReference) + 2
}
if update.Message != nil {
values := messageValues(fields)
values["conversation_id"], values["position"] = conversationID, position
// A reserved answer slot cannot overwrite a later question.
if err := db.WithContext(ctx).Table(messageTable).Create(values).Error; err != nil {
return err
}
}
if update.AppendReference {
raw, err := json.Marshal(update.Reference)
if err != nil {
return err
}
row := map[string]interface{}{"conversation_id": conversationID, "position": position, "reference": string(raw)}
if update.Message != nil {
row["message_position"] = position
}
return db.WithContext(ctx).Table(referenceTable).Create(row).Error
}
return nil
}
func splitHistory(raw json.RawMessage, kind string) ([]json.RawMessage, error) {
raw = bytes.TrimSpace(raw)
if len(raw) == 0 || bytes.Equal(raw, []byte("null")) {
return []json.RawMessage{}, nil
}
if raw[0] == '[' {
var items []json.RawMessage
if err := json.Unmarshal(raw, &items); err != nil {
return nil, err
}
return items, nil
}
if raw[0] != '{' {
return nil, errors.New("conversation history must be a JSON array or object")
}
var object map[string]json.RawMessage
if err := json.Unmarshal(raw, &object); err != nil {
return nil, err
}
if len(object) == 0 {
return []json.RawMessage{}, nil
}
if kind == "message" {
if wrapped, ok := object["messages"]; ok {
return splitHistory(wrapped, kind)
}
return nil, errors.New("conversation message object has no messages array")
}
if _, ok := object["chunks"]; ok {
return []json.RawMessage{raw}, nil
}
if _, ok := object["doc_aggs"]; ok {
return []json.RawMessage{raw}, nil
}
positions := make([]int, 0, len(object))
byPosition := make(map[int]json.RawMessage, len(object))
for key, value := range object {
position, err := strconv.Atoi(key)
if err != nil {
return nil, fmt.Errorf("conversation reference key %q is not numeric", key)
}
positions = append(positions, position)
byPosition[position] = value
}
sort.Ints(positions)
items := make([]json.RawMessage, 0, len(positions))
for _, position := range positions {
items = append(items, byPosition[position])
}
return items, nil
}
func flattenMessage(item json.RawMessage) (entity.ConversationMessageFields, error) {
var fields entity.ConversationMessageFields
var metadata map[string]json.RawMessage
if err := json.Unmarshal(item, &metadata); err != nil {
return fields, err
}
if metadata == nil {
return fields, errors.New("conversation message must be a JSON object")
}
for key, target := range map[string]interface{}{
"id": &fields.MessageID, "role": &fields.Role, "status": &fields.Status,
"thumbup": &fields.ThumbUp, "feedback": &fields.Feedback, "created_at": &fields.CreatedAt,
} {
if value, ok := metadata[key]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
if err := json.Unmarshal(value, target); err == nil {
delete(metadata, key)
} else {
// Keep nonstandard values in metadata without populating the typed column.
if err := json.Unmarshal([]byte("null"), target); err != nil {
return fields, err
}
}
}
}
fields.ContentType = "text"
if value, ok := metadata["content"]; ok {
var content string
if err := json.Unmarshal(value, &content); err != nil || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
content = string(value)
fields.ContentType = "json"
}
fields.Content = &content
delete(metadata, "content")
}
raw, err := json.Marshal(metadata)
fields.Metadata = string(raw)
return fields, err
}
func marshalMessage(fields entity.ConversationMessageFields) (json.RawMessage, error) {
metadata := make(map[string]json.RawMessage)
if len(fields.Metadata) > 0 {
if err := json.Unmarshal([]byte(fields.Metadata), &metadata); err != nil {
return nil, err
}
if metadata == nil {
metadata = make(map[string]json.RawMessage)
}
}
raw, err := json.Marshal(fields)
if err != nil {
return nil, err
}
var values map[string]json.RawMessage
if err := json.Unmarshal(raw, &values); err != nil {
return nil, err
}
for key, value := range values {
metadata[key] = value
}
if fields.Content != nil && fields.ContentType == "json" {
metadata["content"] = json.RawMessage(*fields.Content)
}
return json.Marshal(metadata)
}
func messageValues(fields entity.ConversationMessageFields) map[string]interface{} {
return map[string]interface{}{
"message_id": fields.MessageID, "role": fields.Role, "content": fields.Content, "content_type": fields.ContentType,
"status": fields.Status, "thumb_up": fields.ThumbUp, "feedback": fields.Feedback, "created_at": fields.CreatedAt, "metadata": fields.Metadata,
}
}
func createHistory(ctx context.Context, db *gorm.DB, table, kind, conversationID string, raw json.RawMessage) error {
items, err := splitHistory(raw, kind)
if err != nil {
return fmt.Errorf("split %s history: %w", kind, err)
}
rows := make([]map[string]interface{}, 0, len(items))
for position, item := range items {
row := map[string]interface{}{"conversation_id": conversationID, "position": position, "reference": string(item)}
if kind == "message" {
fields, err := flattenMessage(item)
if err != nil {
return fmt.Errorf("flatten message %d: %w", position, err)
}
row = messageValues(fields)
row["conversation_id"], row["position"] = conversationID, position
}
rows = append(rows, row)
}
if len(rows) == 0 {
return nil
}
return db.WithContext(ctx).Table(table).CreateInBatches(rows, 100).Error
}
func loadHistory(ctx context.Context, db *gorm.DB, table, payloadColumn string, conversationIDs []string) (map[string]json.RawMessage, error) {
result := make(map[string]json.RawMessage, len(conversationIDs))
if len(conversationIDs) == 0 {
return result, nil
}
itemsByConversation := make(map[string][]json.RawMessage, len(conversationIDs))
query := db.WithContext(ctx).Table(table).Where(conversationHistoryIDColumn+" IN ?", conversationIDs).Order(conversationHistoryIDColumn + ", " + conversationHistoryOrderColumn)
if payloadColumn == "message" {
var rows []entity.ConversationMessage
if err := query.Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
raw, err := marshalMessage(row.ConversationMessageFields)
if err != nil {
return nil, fmt.Errorf("marshal message for conversation %s: %w", row.ConversationID, err)
}
itemsByConversation[row.ConversationID] = append(itemsByConversation[row.ConversationID], raw)
}
} else {
var rows []conversationHistoryRow
if err := query.Select(conversationHistoryIDColumn + ", " + conversationHistoryOrderColumn + ", " + payloadColumn + " AS payload").Find(&rows).Error; err != nil {
return nil, err
}
for _, row := range rows {
itemsByConversation[row.ConversationID] = append(itemsByConversation[row.ConversationID], json.RawMessage(row.Payload))
}
}
for _, conversationID := range conversationIDs {
items := itemsByConversation[conversationID]
if items == nil {
items = []json.RawMessage{}
}
raw, err := json.Marshal(items)
if err != nil {
return nil, fmt.Errorf("marshal history for conversation %s: %w", conversationID, err)
}
result[conversationID] = raw
}
return result, nil
}
func hydrateChatSessions(ctx context.Context, db *gorm.DB, sessions []*entity.ChatSession) error {
ids := make([]string, 0, len(sessions))
for _, session := range sessions {
ids = append(ids, session.ID)
}
messages, err := loadHistory(ctx, db, conversationMessageTable, "message", ids)
if err != nil {
return err
}
references, err := loadHistory(ctx, db, conversationReferenceTable, "reference", ids)
if err != nil {
return err
}
for _, session := range sessions {
session.Message = messages[session.ID]
session.Reference = references[session.ID]
}
return nil
}
func hydrateAPIConversations(ctx context.Context, db *gorm.DB, sessions []*entity.API4Conversation) error {
ids := make([]string, 0, len(sessions))
for _, session := range sessions {
ids = append(ids, session.ID)
}
messages, err := loadHistory(ctx, db, apiConversationMessageTable, "message", ids)
if err != nil {
return err
}
references, err := loadHistory(ctx, db, apiConversationReferenceTable, "reference", ids)
if err != nil {
return err
}
for _, session := range sessions {
session.Message = messages[session.ID]
session.Reference = references[session.ID]
}
return nil
}
func deleteHistory(ctx context.Context, db *gorm.DB, tables []string, conversationIDs []string) error {
if len(conversationIDs) == 0 {
return nil
}
for _, table := range tables {
if err := db.WithContext(ctx).Table(table).Where(conversationHistoryIDColumn+" IN ?", conversationIDs).Delete(map[string]interface{}{}).Error; err != nil {
return err
}
}
return nil
}
@@ -0,0 +1,159 @@
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package dao
import (
"context"
"fmt"
"strings"
"testing"
"time"
"github.com/glebarez/sqlite"
"gorm.io/gorm"
"gorm.io/gorm/logger"
"ragflow/internal/entity"
)
type historyBenchmarkLogger struct {
logger.Interface
enabled bool
statements int
selectedRows int64
}
func (l *historyBenchmarkLogger) Trace(_ context.Context, _ time.Time, query func() (string, int64), _ error) {
if !l.enabled {
return
}
sql, rows := query()
l.statements++
if strings.HasPrefix(sql, "SELECT") && rows > 0 {
l.selectedRows += rows
}
}
// BenchmarkConversationHistoryOperations excludes history loading for an LLM
// prompt or a full-history API response. Search misses use 20 conversations.
func BenchmarkConversationHistoryOperations(b *testing.B) {
for _, size := range []int{100, 1000, 5000} {
for _, operation := range []string{"question", "delayed_answer", "search_miss"} {
answer := operation == "delayed_answer"
b.Run(fmt.Sprintf("%s/%d", operation, size), func(b *testing.B) {
stats := &historyBenchmarkLogger{Interface: logger.Default.LogMode(logger.Silent)}
db, err := gorm.Open(sqlite.Open(":memory:"), &gorm.Config{Logger: stats, TranslateError: true})
if err != nil {
b.Fatal(err)
}
sqlDB, err := db.DB()
if err != nil {
b.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
b.Cleanup(func() { _ = sqlDB.Close() })
if err := db.AutoMigrate(&entity.API4Conversation{}, &entity.API4ConversationMessage{}, &entity.API4ConversationReference{}); err != nil {
b.Fatal(err)
}
if err := db.Exec("CREATE INDEX idx_api_4_conversation_dialog_updated ON api_4_conversation (dialog_id, update_time, id)").Error; err != nil {
b.Fatal(err)
}
conversation := &entity.API4Conversation{ID: "history-benchmark", DialogID: "dialog", UserID: "user"}
if err := db.Create(conversation).Error; err != nil {
b.Fatal(err)
}
prologue, userRole, assistantRole, pendingID, content := "Welcome", "user", "assistant", "pending", "message content"
messages := []entity.API4ConversationMessage{
{ConversationID: conversation.ID, Position: 0, ConversationMessageFields: entity.ConversationMessageFields{Role: &assistantRole, Content: &prologue, ContentType: "text", Metadata: "{}"}},
{ConversationID: conversation.ID, Position: 2, ConversationMessageFields: entity.ConversationMessageFields{Role: &userRole, MessageID: &pendingID, Content: &content, ContentType: "text", Metadata: "{}"}},
}
var references []entity.API4ConversationReference
for i := 0; i < (size-2)/2; i++ {
id, position := fmt.Sprintf("turn-%d", i), 4+i*4
messages = append(messages,
entity.API4ConversationMessage{ConversationID: conversation.ID, Position: position, ConversationMessageFields: entity.ConversationMessageFields{Role: &userRole, MessageID: &id, Content: &content, ContentType: "text", Metadata: "{}"}},
entity.API4ConversationMessage{ConversationID: conversation.ID, Position: position + 1, ConversationMessageFields: entity.ConversationMessageFields{Role: &assistantRole, MessageID: &id, Content: &content, ContentType: "text", Metadata: "{}"}})
answerPosition := position + 1
references = append(references, entity.API4ConversationReference{ConversationID: conversation.ID, Position: answerPosition, MessagePos: &answerPosition, Reference: []byte(`{"chunks":[]}`)})
}
if err := db.CreateInBatches(messages, 100).Error; err != nil {
b.Fatal(err)
}
if err := db.CreateInBatches(references, 100).Error; err != nil {
b.Fatal(err)
}
if operation == "search_miss" {
for i := 1; i < 20; i++ {
id := fmt.Sprintf("history-benchmark-%d", i)
if err := db.Create(&entity.API4Conversation{ID: id, DialogID: conversation.DialogID, UserID: conversation.UserID}).Error; err != nil {
b.Fatal(err)
}
for j := range messages {
messages[j].ConversationID = id
}
if err := db.CreateInBatches(messages, 100).Error; err != nil {
b.Fatal(err)
}
}
}
store := NewAPI4ConversationDAO()
update := ConversationHistoryUpdate{Message: map[string]interface{}{"role": "user", "id": "new-question", "content": content, "created_at": 1.0}}
if answer {
update = ConversationHistoryUpdate{Message: map[string]interface{}{"role": "assistant", "id": pendingID, "content": content, "created_at": 2.0}, QuestionID: pendingID, Reference: map[string]interface{}{"chunks": []interface{}{}}, AppendReference: true}
}
b.ResetTimer()
for i := 0; i < b.N; i++ {
if answer && i > 0 {
b.StopTimer()
stats.enabled = false
if err := db.Where("conversation_id = ? AND position = ?", conversation.ID, 3).Delete(&entity.API4ConversationReference{}).Error; err != nil {
b.Fatal(err)
}
if err := db.Where("conversation_id = ? AND position = ?", conversation.ID, 3).Delete(&entity.API4ConversationMessage{}).Error; err != nil {
b.Fatal(err)
}
b.StartTimer()
}
stats.enabled = true
if operation == "search_miss" {
total, sessions, err := NewChatSessionDAO().ListAgentSessions(b.Context(), db, ListAgentSessionsParams{AgentID: conversation.DialogID, Keywords: "absent-keyword", NoHistory: true})
if err != nil || total != 0 || len(sessions) != 0 {
b.Fatalf("search miss: total=%d, sessions=%d, err=%v", total, len(sessions), err)
}
continue
}
if err := store.UpdateHistory(b.Context(), db, conversation.ID, conversation.DialogID, conversation.UserID, nil, update); err != nil {
b.Fatal(err)
}
}
b.StopTimer()
stats.enabled = false
b.ReportMetric(float64(stats.statements)/float64(b.N), "sql/op")
b.ReportMetric(float64(stats.selectedRows)/float64(b.N), "selected_rows/op")
if operation != "search_miss" {
messageID, remaining := "new-question", size+b.N-1
if answer {
messageID, remaining = pendingID, size-1
if err := store.UpdateHistory(b.Context(), db, conversation.ID, conversation.DialogID, conversation.UserID, nil, ConversationHistoryUpdate{FeedbackMessageID: pendingID, Feedback: map[string]interface{}{"thumb_up": true, "feedback": nil}}); err != nil {
b.Fatal(err)
}
}
if err := store.UpdateHistory(b.Context(), db, conversation.ID, conversation.DialogID, conversation.UserID, nil, ConversationHistoryUpdate{DeleteMessageID: messageID}); err != nil {
b.Fatal(err)
}
var messageCount, referenceCount int64
if err := db.Model(&entity.API4ConversationMessage{}).Where("conversation_id = ?", conversation.ID).Count(&messageCount).Error; err != nil {
b.Fatal(err)
}
if err := db.Model(&entity.API4ConversationReference{}).Where("conversation_id = ?", conversation.ID).Count(&referenceCount).Error; err != nil {
b.Fatal(err)
}
if messageCount != int64(remaining) || referenceCount != int64((size-2)/2) {
b.Fatalf("delete must preserve later turns: messages=%d, references=%d", messageCount, referenceCount)
}
}
})
}
}
}
+95
View File
@@ -0,0 +1,95 @@
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package dao
import (
"encoding/json"
"reflect"
"testing"
"ragflow/internal/entity"
)
func TestFlattenMessageRoundTrip(t *testing.T) {
for _, item := range []string{
`{"id":"turn-1","role":"assistant","content":"你好\nanswer","status":"completed","thumbup":false,"feedback":"","created_at":123.25,"attachments":[{"id":"file-1"}]}`,
`{"role":"user","content":[{"type":"text","text":"question"},{"type":"image_url","image_url":{"url":"test"}}],"tool_calls":[]}`,
`{"id":null,"role":null,"content":null,"thumbup":null,"created_at":null,"feedback":null}`,
`{"id":1,"role":false,"content":{"text":"question"},"thumbup":"false","created_at":"123","feedback":{"reason":"bad"},"status":[]}`,
`{}`,
} {
t.Run(item, func(t *testing.T) {
fields, err := flattenMessage(json.RawMessage(item))
if err != nil {
t.Fatal(err)
}
raw, err := marshalMessage(fields)
if err != nil {
t.Fatal(err)
}
var want, got interface{}
if err := json.Unmarshal([]byte(item), &want); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(raw, &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("round trip = %s, want %s", raw, item)
}
})
}
for _, item := range []string{`null`, `[]`, `"text"`} {
if _, err := flattenMessage(json.RawMessage(item)); err == nil {
t.Fatalf("accepted non-object message %s", item)
}
}
}
func TestFlattenedMessagePersistence(t *testing.T) {
for _, table := range []string{conversationMessageTable, apiConversationMessageTable} {
t.Run(table, func(t *testing.T) {
db := setupChatSessionDAOTestDB(t)
parent := "conversation"
if table == apiConversationMessageTable {
parent = "api_4_conversation"
}
values := map[string]interface{}{"id": "session-1", "dialog_id": "dialog-1"}
if parent == "api_4_conversation" {
values["user_id"] = "user-1"
}
if err := db.Table(parent).Create(values).Error; err != nil {
t.Fatal(err)
}
raw := json.RawMessage(`[{"id":"turn-1","role":"assistant","content":"searchable content","thumbup":false,"feedback":"bad","created_at":123.25,"attachment":"file-1"}]`)
if err := createHistory(t.Context(), db, table, "message", "session-1", raw); err != nil {
t.Fatal(err)
}
var row entity.ConversationMessage
if err := db.Table(table).Where("conversation_id = ? AND role = ? AND content LIKE ?", "session-1", "assistant", "%searchable%").First(&row).Error; err != nil {
t.Fatal(err)
}
if row.MessageID == nil || *row.MessageID != "turn-1" || row.ThumbUp == nil || *row.ThumbUp || row.CreatedAt == nil || *row.CreatedAt != 123.25 {
t.Fatalf("unexpected flattened fields: %+v", row.ConversationMessageFields)
}
if string(row.Metadata) != `{"attachment":"file-1"}` {
t.Fatalf("metadata contains flattened fields: %s", row.Metadata)
}
histories, err := loadHistory(t.Context(), db, table, "message", []string{"session-1"})
if err != nil {
t.Fatal(err)
}
var want, got interface{}
if err := json.Unmarshal(raw, &want); err != nil {
t.Fatal(err)
}
if err := json.Unmarshal(histories["session-1"], &got); err != nil {
t.Fatal(err)
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("loaded history = %s, want %s", histories["session-1"], raw)
}
})
}
}
+17
View File
@@ -122,9 +122,13 @@ func InitDB(ctx context.Context, migrateDB bool) error {
&entity.Chat{},
&entity.ChatChannel{},
&entity.ChatSession{},
&entity.ConversationMessage{},
&entity.ConversationReference{},
&entity.Task{},
&entity.APIToken{},
&entity.API4Conversation{},
&entity.API4ConversationMessage{},
&entity.API4ConversationReference{},
&entity.Knowledgebase{},
&entity.InvitationCode{},
&entity.Document{},
@@ -187,6 +191,15 @@ func InitDB(ctx context.Context, migrateDB bool) error {
return fmt.Errorf("failed to auto-migrate runtime models: %w", err)
}
}
// Conversation lists filter by dialog and usually order by update time.
for _, table := range []string{"conversation", "api_4_conversation"} {
indexName := "idx_" + table + "_dialog_updated"
if !DB.WithContext(ctx).Migrator().HasIndex(table, indexName) {
if err = DB.WithContext(ctx).Exec("CREATE INDEX " + indexName + " ON " + table + " (dialog_id, update_time, id)").Error; err != nil {
common.Warn("Failed to create conversation list index", zap.String("table", table), zap.Error(err))
}
}
}
// ingestion_task.pipeline_log_id cannot be added by AutoMigrate (see the
// helper for why), and every ingestion_task query selects all columns, so a
// missing column fails the whole API with Error 1054. Ensure it on both
@@ -310,6 +323,10 @@ func autoMigrateRuntimeModels(ctx context.Context, db *gorm.DB) error {
&entity.IngestionTask{},
&entity.IngestionTaskLog{},
&entity.MemoryTask{},
&entity.ConversationMessage{},
&entity.ConversationReference{},
&entity.API4ConversationMessage{},
&entity.API4ConversationReference{},
}
for _, m := range goRuntimeModels {
if err := autoMigrateSafely(ctx, db, m); err != nil {
+4 -3
View File
@@ -18,15 +18,16 @@ package entity
import "encoding/json"
// API4Conversation API for conversation model
// API4Conversation API for conversation model. Message and Reference are
// hydrated from their ordered child tables.
type API4Conversation struct {
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
Name *string `gorm:"column:name;size:255" json:"name,omitempty"`
DialogID string `gorm:"column:dialog_id;size:32;not null;index" json:"dialog_id"`
UserID string `gorm:"column:user_id;size:255;not null;index" json:"user_id"`
ExpUserID *string `gorm:"column:exp_user_id;size:255;index" json:"exp_user_id,omitempty"`
Message json.RawMessage `gorm:"column:message;type:longtext" json:"message,omitempty"`
Reference json.RawMessage `gorm:"column:reference;type:longtext" json:"reference,omitempty"`
Message json.RawMessage `gorm:"-" json:"message,omitempty"`
Reference json.RawMessage `gorm:"-" json:"reference,omitempty"`
Tokens int `gorm:"column:tokens" json:"tokens"`
Source *string `gorm:"column:source;size:16" json:"source,omitempty"`
DSL JSONMap `gorm:"column:dsl;type:longtext" json:"dsl,omitempty"`
+4 -3
View File
@@ -63,13 +63,14 @@ type ChatListItem struct {
TenantAvatar *string `gorm:"column:tenant_avatar" json:"tenant_avatar,omitempty"`
}
// ChatSession chat session model
// ChatSession chat session model. Message and Reference are hydrated from
// their ordered child tables.
type ChatSession struct {
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
DialogID string `gorm:"column:dialog_id;size:32;not null;index" json:"dialog_id"`
Name *string `gorm:"column:name;size:255;index" json:"name,omitempty"`
Message json.RawMessage `gorm:"column:message;type:longtext" json:"message,omitempty"`
Reference json.RawMessage `gorm:"column:reference;type:longtext" json:"reference"`
Message json.RawMessage `gorm:"-" json:"message,omitempty"`
Reference json.RawMessage `gorm:"-" json:"reference"`
UserID *string `gorm:"column:user_id;size:255;index" json:"user_id,omitempty"`
BaseModel
}
+82
View File
@@ -0,0 +1,82 @@
//
// 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 entity
import "encoding/json"
// ConversationMessageFields contains searchable fields and variable message metadata.
type ConversationMessageFields struct {
MessageID *string `gorm:"column:message_id;size:255;index:,composite:message_lookup,priority:2" json:"id,omitempty"`
Role *string `gorm:"column:role;size:32;index:,composite:message_lookup,priority:3" json:"role,omitempty"`
Content *string `gorm:"column:content;type:longtext" json:"content,omitempty"`
ContentType string `gorm:"column:content_type;size:8" json:"-"`
Status *string `gorm:"column:status;size:32" json:"status,omitempty"`
ThumbUp *bool `gorm:"column:thumb_up" json:"thumbup,omitempty"`
Feedback *string `gorm:"column:feedback;type:longtext" json:"feedback,omitempty"`
CreatedAt *float64 `gorm:"column:created_at" json:"created_at,omitempty"`
Metadata string `gorm:"column:metadata;type:longtext" json:"-"`
}
// ConversationMessage stores one ordered message for a conversation.
type ConversationMessage struct {
ConversationID string `gorm:"column:conversation_id;primaryKey;size:32;index:,composite:message_lookup,priority:1" json:"conversation_id"`
Position int `gorm:"column:position;primaryKey;autoIncrement:false;index:,composite:message_lookup,priority:4" json:"position"`
ConversationMessageFields
Conversation ChatSession `gorm:"foreignKey:ConversationID;references:ID;constraint:OnDelete:CASCADE" json:"-"`
}
func (ConversationMessage) TableName() string {
return "conversation_message"
}
// ConversationReference stores one ordered reference for a conversation.
type ConversationReference struct {
ConversationID string `gorm:"column:conversation_id;primaryKey;size:32" json:"conversation_id"`
Position int `gorm:"column:position;primaryKey;autoIncrement:false" json:"position"`
Reference json.RawMessage `gorm:"column:reference;type:longtext;not null" json:"reference"`
MessagePos *int `gorm:"column:message_position" json:"-"`
Conversation ChatSession `gorm:"foreignKey:ConversationID;references:ID;constraint:OnDelete:CASCADE" json:"-"`
}
func (ConversationReference) TableName() string {
return "conversation_reference"
}
// API4ConversationMessage stores one ordered message for an API conversation.
type API4ConversationMessage struct {
ConversationID string `gorm:"column:conversation_id;primaryKey;size:32;index:,composite:message_lookup,priority:1" json:"conversation_id"`
Position int `gorm:"column:position;primaryKey;autoIncrement:false;index:,composite:message_lookup,priority:4" json:"position"`
ConversationMessageFields
Conversation API4Conversation `gorm:"foreignKey:ConversationID;references:ID;constraint:OnDelete:CASCADE" json:"-"`
}
func (API4ConversationMessage) TableName() string {
return "api_4_conversation_message"
}
// API4ConversationReference stores one ordered reference for an API conversation.
type API4ConversationReference struct {
ConversationID string `gorm:"column:conversation_id;primaryKey;size:32" json:"conversation_id"`
Position int `gorm:"column:position;primaryKey;autoIncrement:false" json:"position"`
Reference json.RawMessage `gorm:"column:reference;type:longtext;not null" json:"reference"`
MessagePos *int `gorm:"column:message_position" json:"-"`
Conversation API4Conversation `gorm:"foreignKey:ConversationID;references:ID;constraint:OnDelete:CASCADE" json:"-"`
}
func (API4ConversationReference) TableName() string {
return "api_4_conversation_reference"
}
+46 -32
View File
@@ -277,9 +277,8 @@ func (h *AgentHandler) ListAgents(c *gin.Context) {
// mapAgentError normalises service-layer errors onto the existing
// {code, data, message} response envelope used by every other handler.
//
// Four classes:
// Three classes:
// - service.ErrAgentNotOwner -> "Only the owner..." (DELETE only, 103)
// - service.ErrAgentSessionBusy -> "session already running" (103)
// - dao.ErrUserCanvasNotFound -> "Make sure you have permission..." (103)
// - service.ErrAgentStorageError -> "Internal storage error" (500)
//
@@ -297,9 +296,6 @@ func mapAgentError(err error) (common.ErrorCode, string) {
if errors.Is(err, service.ErrAgentNotOwner) {
return common.CodeOperatingError, "Only the owner of the agent is authorized for this operation."
}
if errors.Is(err, service.ErrAgentSessionBusy) {
return common.CodeOperatingError, "This agent session is already running."
}
if errors.Is(err, dao.ErrUserCanvasNotFound) ||
errors.Is(err, dao.ErrUserCanvasVersionNotFound) {
return common.CodeOperatingError, "Make sure you have permission to access the agent."
@@ -504,7 +500,11 @@ func (h *AgentHandler) RunAgent(c *gin.Context) {
// Persistence of the session record remains owned by AgentService.RunAgent.
sessionID = utility.GenerateToken()
}
userInput := readUserInput(c)
userInput, err := readUserInput(c)
if err != nil {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, err.Error())
return
}
events, err := h.chatRunner.RunAgent(c.Request.Context(), user.ID, canvasID, sessionID, version, userInput, nil)
if err != nil {
@@ -544,26 +544,29 @@ func (h *AgentHandler) RunAgent(c *gin.Context) {
// present, otherwise from the ?user_input= query string. An empty body
// (no body sent) is treated as "" so the resume cycle still works
// when the client only passes ?session_id=...&user_input=... on the URL.
func readUserInput(c *gin.Context) string {
if c.Request.ContentLength > 0 {
func readUserInput(c *gin.Context) (string, error) {
if c.Request.ContentLength != 0 {
var body struct {
UserInput string `json:"user_input"`
Query string `json:"query"`
Message string `json:"message"`
UserInput string `json:"user_input"`
Question string `json:"question"`
Messages []map[string]interface{} `json:"messages"`
Query string `json:"query"`
Message string `json:"message"`
}
if err := c.ShouldBindJSON(&body); err == nil {
if body.UserInput != "" {
return body.UserInput
question, err := service.ResolveCompletionQuestion(body.Question, body.Query, body.Messages)
if err != nil || question != "" {
return question, err
}
if body.Query != "" {
return body.Query
if body.UserInput != "" {
return body.UserInput, nil
}
if body.Message != "" {
return body.Message
return body.Message, nil
}
}
}
return c.Query("user_input")
return c.Query("user_input"), nil
}
// runCanvasPipelineDebug runs a canvas in pipeline dry-run (debug) mode: it builds
@@ -944,6 +947,7 @@ func (h *AgentHandler) ListAgentSessions(c *gin.Context) {
UserID: queryUserID,
ExpUserID: expUserID,
IncludeDSL: includeDSL,
NoHistory: c.Query("include_history") == "false" || c.Query("include_history") == "False",
})
if err != nil {
common.ErrorWithCode(c, code, err.Error())
@@ -1062,6 +1066,7 @@ func (h *AgentHandler) DeleteAgentSession(c *gin.Context) {
// adapter lives in agent_openai.go so the regular Agent event contract
// remains unchanged.
type agentChatCompletionsRequest struct {
Question string `json:"question,omitempty"`
AgentID string `json:"agent_id"`
Query string `json:"query"`
Inputs map[string]interface{} `json:"inputs"`
@@ -1111,23 +1116,20 @@ func (f *agentFiles) UnmarshalJSON(data []byte) error {
return nil
}
// extractLastUserContent returns the content of the last message in
// `messages` whose role is "user", or "" if none is found. Mirrors the
// Python derivation in api/apps/restful_apis/agent_api.py:1258 that drives
// `completion_openai` when the request uses the openai-compatible wire
// format but no top-level `query` is supplied.
// extractLastUserContent returns the latest message's content only if it is from a user.
func extractLastUserContent(messages []map[string]interface{}) string {
for i := len(messages) - 1; i >= 0; i-- {
role, _ := messages[i]["role"].(string)
if role != "user" {
continue
}
content, err := service.NormalizeOpenAIMessageContent(messages[i]["content"])
if err == nil && content != "" {
return content
}
if len(messages) == 0 {
return ""
}
return ""
message := messages[len(messages)-1]
if message["role"] != "user" {
return ""
}
content, err := service.NormalizeOpenAIMessageContent(message["content"])
if err != nil {
return ""
}
return content
}
// extractUserInputFromFormInputs mirrors the front-end's wait-for-user submit
@@ -1211,6 +1213,18 @@ func (h *AgentHandler) AgentChatCompletions(c *gin.Context) {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "Invalid request: "+err.Error())
return
}
question, err := service.ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, err.Error())
return
}
req.Query = question
if req.Question != "" || req.Query != "" {
req.Messages = []map[string]interface{}{{"role": "user", "content": question}}
}
if len(req.Messages) > 0 {
req.Messages = req.Messages[len(req.Messages)-1:]
}
if req.AgentID == "" {
common.ResponseWithCodeData(c, common.CodeArgumentError, nil, "`agent_id` is required.")
return
+2 -6
View File
@@ -341,7 +341,7 @@ func writeOpenAICompatError(c *gin.Context, err error) {
if errors.As(err, &codedErr) {
code, message = codedErr.Code, codedErr.Message
}
status, errorType := openAICompatErrorResponse(code, err)
status, errorType := openAICompatErrorResponse(code)
c.JSON(status, gin.H{
"error": gin.H{
"message": message,
@@ -350,11 +350,7 @@ func writeOpenAICompatError(c *gin.Context, err error) {
})
}
func openAICompatErrorResponse(code common.ErrorCode, err error) (int, string) {
if errors.Is(err, service.ErrAgentSessionBusy) {
return http.StatusConflict, "invalid_request_error"
}
func openAICompatErrorResponse(code common.ErrorCode) (int, string) {
switch code {
case common.CodeArgumentError, common.CodeDataError, common.CodeBadRequest, common.CodeParamError:
return http.StatusBadRequest, "invalid_request_error"
+2 -12
View File
@@ -1132,12 +1132,9 @@ func TestAgentChatCompletions_OpenAICompat_NonStreamReturnsCompletion(t *testing
if !ok {
t.Fatalf("usage = %#v, want object", resp["usage"])
}
wantPromptTokens := tokenizer.NumTokensFromString("Be concise.") +
tokenizer.NumTokensFromString("What is 1+1?") +
tokenizer.NumTokensFromString("2") +
tokenizer.NumTokensFromString("hi")
wantPromptTokens := tokenizer.NumTokensFromString("hi")
if got := int(usage["prompt_tokens"].(float64)); got != wantPromptTokens {
t.Errorf("prompt_tokens = %d, want all message content counted as %d", got, wantPromptTokens)
t.Errorf("prompt_tokens = %d, want only the latest message content counted as %d", got, wantPromptTokens)
}
if usage["total_tokens"].(float64) != usage["prompt_tokens"].(float64)+usage["completion_tokens"].(float64) {
t.Errorf("usage totals do not add up: %v", usage)
@@ -1301,13 +1298,6 @@ func TestAgentChatCompletions_OpenAICompat_MapsErrors(t *testing.T) {
errorType string
wantMessage string
}{
{
name: "session busy",
err: service.ErrAgentSessionBusy,
status: http.StatusConflict,
errorType: "invalid_request_error",
wantMessage: "already running",
},
{
name: "operating error",
err: service.ErrAgentNotOwner,
+2 -4
View File
@@ -605,7 +605,7 @@ func (h *AgentHandler) runWebhookSync(
h.appendWebhookFinishedTrace(ctx, cv.ID, startTs, sessionID, false)
}
code, message := mapAgentError(err)
return newWebhookFailureResult(webhookHTTPStatusForAgentError(code, err), message, sessionID)
return newWebhookFailureResult(webhookHTTPStatusForAgentError(code), message, sessionID)
}
contents := []string{}
@@ -689,12 +689,10 @@ func webhookStartErrorEvent(err error, sessionID string) canvas.RunEvent {
}
}
func webhookHTTPStatusForAgentError(code common.ErrorCode, err error) int {
func webhookHTTPStatusForAgentError(code common.ErrorCode) int {
switch {
case code == common.CodeServerError:
return http.StatusInternalServerError
case errors.Is(err, service.ErrAgentSessionBusy):
return http.StatusConflict
case code == common.CodeOperatingError:
return http.StatusForbidden
default:
+32 -47
View File
@@ -103,7 +103,8 @@ func (h *ChatSessionHandler) ListChatSessions(c *gin.Context) {
// Call service to list chat sessions
ctx := c.Request.Context()
result, err := h.chatSessionService.ListChatSessions(ctx, userID, chatID, c.Query("id"), c.Query("name"), orderby, desc, page, pageSize)
includeHistory := c.DefaultQuery("include_history", "true")
result, err := h.chatSessionService.ListChatSessions(ctx, userID, chatID, c.Query("id"), c.Query("name"), orderby, desc, page, pageSize, includeHistory != "false" && includeHistory != "False")
if err != nil {
// Mirror Python: ownership failures return code 109 "no authorization"
if strings.Contains(err.Error(), "no authorization") {
@@ -118,31 +119,31 @@ func (h *ChatSessionHandler) ListChatSessions(c *gin.Context) {
}
type ChatCompletionsRequest struct {
ChatID string `json:"chat_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
Messages []map[string]interface{} `json:"messages,omitempty"`
Question string `json:"question,omitempty"`
Files []interface{} `json:"files,omitempty"`
LLMID string `json:"llm_id,omitempty"`
PassAllHistoryMessages *bool `json:"pass_all_history_messages,omitempty"`
PassAllHistory *bool `json:"pass_all_history,omitempty"`
StoreHistoryMessages *bool `json:"store_history_messages,omitempty"`
StoreHistory *bool `json:"store_history,omitempty"`
Legacy bool `json:"legacy,omitempty"`
Stream *bool `json:"stream"`
Thinking string `json:"thinking"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
ChatID string `json:"chat_id,omitempty"`
SessionID string `json:"session_id,omitempty"`
ConversationID string `json:"conversation_id,omitempty"`
Messages []map[string]interface{} `json:"messages,omitempty"`
Question string `json:"question,omitempty"`
Query string `json:"query,omitempty"`
Files []interface{} `json:"files,omitempty"`
LLMID string `json:"llm_id,omitempty"`
Legacy bool `json:"legacy,omitempty"`
Stream *bool `json:"stream"`
Thinking string `json:"thinking"`
Temperature *float64 `json:"temperature,omitempty"`
TopP *float64 `json:"top_p,omitempty"`
FrequencyPenalty *float64 `json:"frequency_penalty,omitempty"`
PresencePenalty *float64 `json:"presence_penalty,omitempty"`
MaxTokens *int `json:"max_tokens,omitempty"`
StoreHistoryMessages *bool `json:"store_history_messages,omitempty"`
}
// ChatCompletions chat completion
// @Summary Chat Completion
// @Description Send messages to the chat model and get a response.
// @Description Default is streaming (text/event-stream); set stream:false for JSON.
// @Description Set store_history_messages:false to use the full messages payload without storing history. No other explicit value is supported.
// @Tags chat_session
// @Accept json
// @Produce json, text/event-stream
@@ -163,6 +164,10 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) {
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
return
}
if _, err := service.ResolveStoreHistoryMessages(rawBody); err != nil {
common.ErrorWithCode(c, common.CodeBadRequest, err.Error())
return
}
var req ChatCompletionsRequest
b, err := json.Marshal(rawBody)
@@ -175,6 +180,10 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) {
return
}
if req.Question == "" {
req.Question = req.Query
}
// Normalize session_id / conversation_id
sessionID := req.SessionID
if sessionID == "" {
@@ -212,35 +221,11 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) {
genConfig["max_tokens"] = *req.MaxTokens
}
// Resolve pass_all_history from either alias
passAllHistory := false
if req.PassAllHistory != nil {
passAllHistory = *req.PassAllHistory
}
if req.PassAllHistoryMessages != nil {
passAllHistory = *req.PassAllHistoryMessages
}
// Resolve store_history from either alias (default true, mirrors Python)
storeHistory := true
if req.StoreHistory != nil {
storeHistory = *req.StoreHistory
}
if req.StoreHistoryMessages != nil {
storeHistory = *req.StoreHistoryMessages
}
if !storeHistory && !passAllHistory {
common.ErrorWithCode(c, common.CodeDataError, "`pass_all_history_messages` must be true when `store_history_messages` is false.")
return
}
// Remove known keys from rawBody; what remains is passthrough kwargs
knownKeys := []string{
"chat_id", "session_id", "conversation_id",
"messages", "question", "files",
"messages", "question", "query", "files",
"llm_id",
"pass_all_history_messages", "pass_all_history",
"store_history_messages", "store_history",
"legacy", "stream", "thinking",
"temperature", "top_p", "frequency_penalty", "presence_penalty", "max_tokens",
}
@@ -271,7 +256,7 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) {
req.ChatID, sessionID,
req.Messages, req.Question, req.Files,
req.LLMID, genConfig, kwargs,
passAllHistory, storeHistory, req.Legacy,
req.Legacy,
true, streamChan,
)
}()
@@ -291,7 +276,7 @@ func (h *ChatSessionHandler) ChatCompletions(c *gin.Context) {
req.ChatID, sessionID,
req.Messages, req.Question, req.Files,
req.LLMID, genConfig, kwargs,
passAllHistory, storeHistory, req.Legacy,
req.Legacy,
false, nil,
)
if err != nil {
+18
View File
@@ -77,6 +77,24 @@ func TestChatSessionHandlerUpdateMessageFeedback_RejectsEmptyJSONObject(t *testi
func TestChatSessionHandlerChatCompletionsRejectsNonPositiveMaxTokens(t *testing.T) {
gin.SetMode(gin.TestMode)
for _, value := range []string{"true", `"false"`, "null", "0", "{}"} {
t.Run("store_history_messages="+value, func(t *testing.T) {
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
ctx.Request = httptest.NewRequest(http.MethodPost, "/api/v1/chat/completions", strings.NewReader(`{"store_history_messages":`+value+`,"messages":[{"role":"user","content":"hi"}]}`))
ctx.Request.Header.Set("Content-Type", "application/json")
ctx.Set("user", &entity.User{ID: "user-1"})
handler := NewChatSessionHandler(service.NewChatSessionService(), nil)
handler.ChatCompletions(ctx)
var body map[string]interface{}
if err := json.Unmarshal(recorder.Body.Bytes(), &body); err != nil {
t.Fatalf("decode response body: %v", err)
}
if body["code"] != float64(common.CodeBadRequest) || body["message"] != "`store_history_messages` only supports false." {
t.Fatalf("unexpected response: %#v", body)
}
})
}
recorder := httptest.NewRecorder()
ctx, _ := gin.CreateTestContext(recorder)
+9
View File
@@ -69,6 +69,15 @@ func (h *OpenAIChatHandler) OpenAIChatCompletions(c *gin.Context) {
return
}
question, err := service.ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
common.ResponseWithCodeData(c, common.CodeDataError, nil, err.Error())
return
}
if req.Question != "" || req.Query != "" {
req.Messages = []map[string]interface{}{{"role": "user", "content": question}}
}
// Messages presence
if len(req.Messages) == 0 {
common.ResponseWithCodeData(c, common.CodeDataError, nil, "You have to provide messages.")
+13 -1
View File
@@ -873,6 +873,8 @@ func (h *ProviderHandler) DropInstanceModels(c *gin.Context) {
}
type ChatToModelRequest struct {
Question string `json:"question,omitempty"`
Query string `json:"query,omitempty"`
ProviderName *string `json:"provider_name"`
InstanceName *string `json:"instance_name"`
ModelName *string `json:"model_name"`
@@ -893,6 +895,17 @@ func (h *ProviderHandler) ChatToModel(c *gin.Context) {
return
}
question, err := service.ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
common.ErrorWithCode(c, common.CodeArgumentError, err.Error())
return
}
if req.Question != "" || req.Query != "" {
req.Messages = []map[string]interface{}{{"role": "user", "content": question}}
}
if len(req.Messages) > 0 {
req.Messages = req.Messages[len(req.Messages)-1:]
}
if req.ModelID == nil {
if req.ProviderName == nil || *req.ProviderName == "" {
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, 400, nil, "Provider name is required")
@@ -1013,7 +1026,6 @@ func (h *ProviderHandler) ChatToModel(c *gin.Context) {
// Non-stream response
var response *models.ChatResponse
var errorCode common.ErrorCode
var err error
// Convert []map[string]interface{} to []models.Message
messages := make([]models.Message, len(req.Messages))
+12 -3
View File
@@ -34,9 +34,11 @@ import (
// SearchBotAskRequest is the request body for POST /api/v1/searchbots/ask.
type SearchBotAskRequest struct {
Question string `json:"question" binding:"required"`
KbIDs common.StringSlice `json:"kb_ids" binding:"required"`
SearchID string `json:"search_id,omitempty"`
Query string `json:"query,omitempty"`
Messages []map[string]interface{} `json:"messages,omitempty"`
Question string `json:"question"`
KbIDs common.StringSlice `json:"kb_ids" binding:"required"`
SearchID string `json:"search_id,omitempty"`
}
// SearchBotMindMapRequest is the request body for POST /api/v1/searchbots/mindmap.
@@ -237,6 +239,13 @@ func (h *SearchBotHandler) Ask(c *gin.Context) {
return
}
question, err := service.ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
common.ResponseWithHttpCodeData(c, http.StatusBadRequest, common.CodeArgumentError, nil, err.Error())
return
}
req.Question = question
// Filter empty kb_ids.
filtered := make(common.StringSlice, 0, len(req.KbIDs))
for _, id := range req.KbIDs {
+6
View File
@@ -1191,6 +1191,12 @@ func Rag(ctx context.Context, deps RAGTools, req harness.RunRequest) *RunRespons
// schema import.
if len(deps.Messages) == 0 {
deps.Messages = multimodalUserMessage(req.Question, req.TextAttachments, req.Images)
} else if req.TextAttachments != "" || len(req.Images) > 0 {
last := len(deps.Messages) - 1
if deps.Messages[last].Role == schema.User {
deps.Messages = append([]schema.Message(nil), deps.Messages...)
deps.Messages[last] = multimodalUserMessage(deps.Messages[last].Content, req.TextAttachments, req.Images)[0]
}
}
// Text attachments also feed the direct (non-outer) path, which appends them
// to the question (Python text_attachments_content handling); attachments
+111 -74
View File
@@ -326,10 +326,6 @@ func splitMessageContent(content string) []string {
// ErrAgentNotOwner (owner).
var ErrAgentNotOwner = errors.New("agent not owned by user")
// ErrAgentSessionBusy is returned when a second request attempts to run the
// same Agent session before the current run reaches a terminal state.
var ErrAgentSessionBusy = errors.New("agent session is already running")
// ErrAgentStorageError identifies internal Agent service failures such as
// database connectivity, schema drift, or persistence errors. Synchronous
// callers map this sentinel to a sanitized 500 response; failures raised after
@@ -1455,6 +1451,7 @@ func (s *AgentService) DeleteVersion(ctx context.Context, userID, canvasID, vers
// for the full production chain (real Compile/Invoke, resume path,
// error-layering contract).
func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID, version string, userInput any, files []map[string]interface{}) (<-chan canvas.RunEvent, error) {
receivedAt := float64(time.Now().UnixNano()) / 1e9
canvasRow, err := s.loadCanvasForUser(ctx, userID, canvasID)
if err != nil {
return nil, err
@@ -1463,6 +1460,31 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
if sessionID == "" {
sessionID = utility.GenerateToken()
}
messageID := utility.GenerateToken()
questionSaved := false
persistQuestion := func() error {
if questionSaved || s.api4ConversationDAO == nil || dao.DB == nil {
return nil
}
session, err := s.api4ConversationDAO.GetMetadataBySessionID(ctx, dao.DB, sessionID, canvasID)
if err != nil {
return fmt.Errorf("RunAgent: load session: %w: %w", err, ErrAgentStorageError)
}
if session == nil {
return nil
}
if session.UserID != userID {
return fmt.Errorf("RunAgent: session %q not found: %w", sessionID, dao.ErrUserCanvasNotFound)
}
if err := s.persistAgentRunQuestion(ctx, canvasID, userID, sessionID, messageID, userInput, receivedAt); err != nil {
return fmt.Errorf("RunAgent: persist question: %w: %w", err, ErrAgentStorageError)
}
questionSaved = true
return nil
}
if err := persistQuestion(); err != nil {
return nil, err
}
runID := runIDFor(canvasID, map[string]any{"session_id": sessionID})
lockToken := utility.GenerateToken()
runCtx, cancelRun := context.WithCancel(ctx)
@@ -1484,45 +1506,60 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
}
s.runMu.Unlock()
}
// Make the distributed lease the first run-lifecycle mutation after canvas
// access is authorized. All version, session, and DSL initialization happens
// only after other instances can observe and cancel this starting run.
if s.runTracker != nil {
registered, registerErr := s.runTracker.RegisterActiveSession(ctx, canvas.ActiveSession{
SessionID: sessionID,
Token: lockToken,
UserID: userID,
CanvasID: canvasID,
RunID: runID,
})
if registerErr != nil {
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
_, _ = s.runTracker.ReleaseActiveSession(cleanupCtx, sessionID, lockToken)
cleanupCancel()
releaseLocal()
// Accept concurrent questions, but serialize stateful runs so checkpoints
// and persisted DSL cannot be overwritten by another run of this session.
wait := time.NewTicker(100 * time.Millisecond)
defer wait.Stop()
for {
if err := ctx.Err(); err != nil {
cancelRun()
return nil, fmt.Errorf("RunAgent: register active session: %w: %w", registerErr, ErrAgentStorageError)
return nil, err
}
if !registered {
releaseLocal()
cancelRun()
return nil, ErrAgentSessionBusy
}
}
s.runMu.Lock()
if _, exists := s.activeSessions[sessionID]; exists {
s.runMu.Unlock()
registered := true
if s.runTracker != nil {
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
_, _ = s.runTracker.ReleaseActiveSession(cleanupCtx, sessionID, lockToken)
cleanupCancel()
var registerErr error
registered, registerErr = s.runTracker.RegisterActiveSession(ctx, canvas.ActiveSession{
SessionID: sessionID,
Token: lockToken,
UserID: userID,
CanvasID: canvasID,
RunID: runID,
})
if registerErr != nil {
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
_, _ = s.runTracker.ReleaseActiveSession(cleanupCtx, sessionID, lockToken)
cleanupCancel()
cancelRun()
return nil, fmt.Errorf("RunAgent: register active session: %w: %w", registerErr, ErrAgentStorageError)
}
}
if registered {
s.runMu.Lock()
_, busy := s.activeSessions[sessionID]
if !busy {
s.activeSessions[sessionID] = active
}
s.runMu.Unlock()
if !busy {
break
}
if s.runTracker != nil {
cleanupCtx, cleanupCancel := context.WithTimeout(context.WithoutCancel(ctx), time.Second)
_, _ = s.runTracker.ReleaseActiveSession(cleanupCtx, sessionID, lockToken)
cleanupCancel()
}
}
if err := persistQuestion(); err != nil {
cancelRun()
return nil, err
}
select {
case <-ctx.Done():
cancelRun()
return nil, ctx.Err()
case <-wait.C:
}
cancelRun()
return nil, ErrAgentSessionBusy
}
s.activeSessions[sessionID] = active
s.runMu.Unlock()
if s.runTracker == nil {
// Without the distributed registry, clear a marker left by a prior
@@ -1664,7 +1701,7 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
}
sessionFound := false
if sessionID != "" && s.api4ConversationDAO != nil {
session, sessionErr := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, canvasID)
session, sessionErr := s.api4ConversationDAO.GetMetadataBySessionID(ctx, dao.DB, sessionID, canvasID)
if sessionErr != nil {
return nil, fmt.Errorf("RunAgent: load session %q: %w: %w", sessionID, sessionErr, ErrAgentStorageError)
}
@@ -1690,18 +1727,20 @@ func (s *AgentService) RunAgent(ctx context.Context, userID, canvasID, sessionID
}
}
if err := persistQuestion(); err != nil {
return nil, err
}
run := s.buildRunFunc(canvasID, versionRow, dsl)
root := map[string]any{
"canvas_id": canvasID,
"version_id": version,
"session_id": sessionID,
"user_id": userID,
"__message_id__": messageID,
"canvas_id": canvasID,
"version_id": version,
"session_id": sessionID,
"user_id": userID,
}
// The session row above was created by this request (first touch);
// if the run then fails, the run closure drops the row again so a
// failed exploration never shows up in the session list.
if !sessionFound || newSession {
// Drop failed first-touch sessions only when there was no user question.
if (!sessionFound || newSession) && stringifyAgentUserInput(agentRunQuery(userInput)) == "" {
root["__drop_session_on_failure__"] = true
}
// The stable run id is derived from the canvas and session. It is only a
@@ -1867,9 +1906,8 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
sessionID, _ := root["__session_id__"].(string)
userID, _ := root["user_id"].(string)
// A failed first-touch run must not leave the freshly-created
// empty session row behind — otherwise every failed exploration
// inflates the session list with a title-less conversation.
// A failed first-touch run with no question should not leave an empty session.
// Sessions with accepted user questions remain in history on failure.
// Interrupts (UserFillUp waits) and user-initiated cancels keep
// the row: both are resumable, visible states, not failures.
if dropOnFailure, _ := root["__drop_session_on_failure__"].(bool); dropOnFailure {
@@ -1946,6 +1984,9 @@ func (s *AgentService) buildRunFunc(canvasID string, versionRow *entity.UserCanv
if versionRow == nil && len(dsl) == 0 {
answer := fmt.Sprintf("No published version found for canvas %q — publish a version before running.", canvasID)
state.RecordOutput("answer", "answer", answer)
if err := s.persistAgentRunSession(ctx, canvasID, userID, sessionID, messageID, userInput, answer, "", nil, dsl, state, true); err != nil {
return nil, canvas.NewInternalRunError(fmt.Errorf("persist agent session: %w: %w", err, ErrAgentStorageError))
}
// Emit a message event so the SSE surface matches the
// normal-completion shape (test asserts message +
// workflow_finished + done for the placeholder path).
@@ -2425,6 +2466,18 @@ func emptyDownloadValue(value any) bool {
}
}
func (s *AgentService) persistAgentRunQuestion(ctx context.Context, agentID, userID, sessionID, messageID string, userInput any, receivedAt float64) error {
if sessionID == "" || s == nil || s.api4ConversationDAO == nil || dao.DB == nil {
return nil
}
text := stringifyAgentUserInput(agentRunQuery(userInput))
if text == "" {
return nil
}
message := map[string]interface{}{"role": "user", "content": text, "id": messageID, "created_at": receivedAt}
return s.api4ConversationDAO.UpdateHistory(ctx, dao.DB, sessionID, agentID, userID, nil, dao.ConversationHistoryUpdate{Message: message})
}
func (s *AgentService) persistAgentRunSession(
ctx context.Context,
agentID, userID, sessionID, messageID string,
@@ -2436,38 +2489,22 @@ func (s *AgentService) persistAgentRunSession(
state *canvas.CanvasState,
appendAssistantMessage bool,
) error {
now := float64(time.Now().UnixNano()) / 1e9
if sessionID == "" || s == nil || s.api4ConversationDAO == nil || dao.DB == nil {
return nil
}
session, err := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, agentID)
if err != nil {
common.Warn("agent run: load session for update failed", zap.String("agent_id", agentID), zap.String("session_id", sessionID), zap.Error(err))
return nil
}
if session == nil || session.UserID != userID {
return nil
}
messages := parseAgentSessionMessages(session.Message)
now := time.Now().Unix()
if text := stringifyAgentUserInput(agentRunQuery(userInput)); text != "" {
messages = append(messages, map[string]interface{}{"role": "user", "content": text, "id": utility.GenerateToken(), "created_at": now})
history := dao.ConversationHistoryUpdate{Reference: normalizeAgentReferenceEntry(reference), AppendReference: true}
if stringifyAgentUserInput(agentRunQuery(userInput)) != "" {
history.QuestionID = messageID
}
if appendAssistantMessage {
messages = append(messages, map[string]interface{}{"role": "assistant", "content": agentSessionMessageContent(answer, thinking), "id": messageID, "created_at": now})
}
if raw, err := json.Marshal(messages); err == nil {
session.Message = raw
}
references := parseAgentSessionReferences(session.Reference)
references = append(references, normalizeAgentReferenceEntry(reference))
if raw, err := json.Marshal(references); err == nil {
session.Reference = raw
history.Message = map[string]interface{}{"role": "assistant", "content": agentSessionMessageContent(answer, thinking), "id": messageID, "created_at": now}
}
updates := map[string]interface{}{"round": gorm.Expr("COALESCE(round, 0) + 1")}
if state != nil {
session.DSL = buildPersistedAgentDSL(runDSL, state)
updates["dsl"] = buildPersistedAgentDSL(runDSL, state)
}
session.Round++
return s.api4ConversationDAO.Update(ctx, dao.DB, session)
return s.api4ConversationDAO.UpdateHistory(ctx, dao.DB, sessionID, agentID, userID, updates, history)
}
func buildPersistedAgentDSL(runDSL map[string]any, state *canvas.CanvasState) entity.JSONMap {
+3 -2
View File
@@ -15,6 +15,7 @@ import (
"github.com/redis/go-redis/v9"
"ragflow/internal/agent/canvas"
"ragflow/internal/dao"
"ragflow/internal/entity"
)
@@ -81,13 +82,13 @@ func TestCancelSessionRunDoesNotAffectAnotherSession(t *testing.T) {
func TestCancelSessionRunFinishedSessionDoesNotCreateCancelMarker(t *testing.T) {
testDB := setupServiceTestDB(t)
pushServiceDB(t, testDB)
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-persisted",
DialogID: "agent-1",
UserID: "user-a",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create conversation: %v", err)
}
+84 -6
View File
@@ -22,6 +22,7 @@ import (
"errors"
"strings"
"testing"
"time"
"ragflow/internal/agent/canvas"
"ragflow/internal/dao"
@@ -37,13 +38,13 @@ func TestAgentRunSessionUpdateFailurePreventsSuccessEvents(t *testing.T) {
dao.DB = testDB
t.Cleanup(func() { dao.DB = originalDB })
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-update-failure",
DialogID: "canvas-update-failure",
UserID: "user-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create session: %v", err)
}
if err := testDB.Exec(`
@@ -126,23 +127,26 @@ func TestPersistAgentRunSessionPreservesThinking(t *testing.T) {
dao.DB = testDB
t.Cleanup(func() { dao.DB = originalDB })
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-think",
DialogID: "canvas-think",
UserID: "user-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create session: %v", err)
}
svc := NewAgentService()
if err := svc.persistAgentRunQuestion(t.Context(), "canvas-think", "user-1", "session-think", "msg-think-1", "question", 1); err != nil {
t.Fatalf("persist question: %v", err)
}
if err := svc.persistAgentRunSession(context.Background(), "canvas-think", "user-1", "session-think", "msg-think-1", "question", "final answer", "reasoning trace", map[string]interface{}{}, nil, nil, true); err != nil {
t.Fatalf("persist: %v", err)
}
var conv entity.API4Conversation
if err := testDB.First(&conv, "id = ?", "session-think").Error; err != nil {
conv, err := dao.NewAPI4ConversationDAO().GetByID(t.Context(), testDB, "session-think")
if err != nil {
t.Fatalf("reload session: %v", err)
}
var messages []map[string]any
@@ -158,4 +162,78 @@ func TestPersistAgentRunSessionPreservesThinking(t *testing.T) {
if got, _ := messages[1]["content"].(string); got != "<think>reasoning trace</think>final answer" {
t.Fatalf("persisted assistant content = %q, want %q", got, "<think>reasoning trace</think>final answer")
}
// Both requests must save their questions while the session is busy,
// then complete without overwriting either answer or immutable reference.
if err := testDB.AutoMigrate(&entity.UserCanvas{}, &entity.UserCanvasVersion{}); err != nil {
t.Fatal(err)
}
dsl := entity.JSONMap{"components": map[string]any{
"begin_0": map[string]any{"obj": map[string]any{"component_name": "Begin", "params": map[string]any{}}, "downstream": []any{"message_0"}},
"message_0": map[string]any{"obj": map[string]any{"component_name": "Message", "params": map[string]any{"text": "answer {{sys.query}}"}}, "upstream": []any{"begin_0"}},
}, "path": []any{"begin_0", "message_0"}}
if err := testDB.Create(&entity.UserCanvas{ID: "canvas-think", UserID: "user-1", DSL: dsl}).Error; err != nil {
t.Fatal(err)
}
sqlDB, err := testDB.DB()
if err != nil {
t.Fatal(err)
}
sqlDB.SetMaxOpenConns(1)
svc.activeSessions["session-think"] = &activeAgentRun{sessionID: "session-think"}
ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second)
defer cancel()
results := make(chan error, 2)
for _, question := range []string{"concurrent question 1", "concurrent question 2"} {
go func(question string) {
events, err := svc.RunAgent(ctx, "user-1", "canvas-think", "session-think", "", question, nil)
if err == nil {
for event := range events {
if event.Type == "error" {
err = errors.New(event.Data)
}
}
}
results <- err
}(question)
}
wait := time.NewTicker(10 * time.Millisecond)
defer wait.Stop()
for {
var questions int64
if err := testDB.Model(&entity.API4ConversationMessage{}).Where("conversation_id = ? AND role = ?", "session-think", "user").Count(&questions).Error; err != nil {
t.Fatal(err)
}
if questions == 3 {
break
}
select {
case err := <-results:
t.Fatalf("queued request finished before the active run was released: %v", err)
case <-ctx.Done():
t.Fatal("concurrent questions were not saved immediately")
case <-wait.C:
}
}
svc.runMu.Lock()
delete(svc.activeSessions, "session-think")
svc.runMu.Unlock()
for range 2 {
if err := <-results; err != nil {
t.Fatalf("concurrent run: %v", err)
}
}
conv, err = dao.NewAPI4ConversationDAO().GetByID(ctx, testDB, "session-think")
if err != nil {
t.Fatal(err)
}
messages = parseMessages(conv.Message)
if len(messages) != 6 || len(parseReferenceList(conv.Reference)) != 3 {
t.Fatalf("concurrent turns were lost: messages=%s references=%s", conv.Message, conv.Reference)
}
for _, index := range []int{2, 4} {
if messages[index]["role"] != "user" || messages[index+1]["role"] != "assistant" || messages[index]["id"] != messages[index+1]["id"] {
t.Fatalf("answer is not associated with its question: %#v", messages[index:index+2])
}
}
}
+19 -35
View File
@@ -43,6 +43,7 @@ import (
"errors"
"os"
"path/filepath"
"reflect"
"strings"
"testing"
"time"
@@ -279,13 +280,13 @@ func TestRunAgent_SessionHistoryFeedsSysHistoryAndPersists(t *testing.T) {
"path": []any{"begin_0", "history_0", "message_0"},
}
makeCanvasWithDSL(t, "canvas-history", "user-1", "tenant-1", "v-history", dsl)
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-history",
DialogID: "canvas-history",
UserID: "user-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create session: %v", err)
}
@@ -310,10 +311,7 @@ func TestRunAgent_SessionHistoryFeedsSysHistoryAndPersists(t *testing.T) {
if first.Content != `["user: hi"]` {
t.Fatalf("first content = %q, want JSON-rendered sys.history", first.Content)
}
var afterFirst entity.API4Conversation
if err := testDB.Where("id = ?", "session-history").First(&afterFirst).Error; err != nil {
t.Fatalf("reload session after first run: %v", err)
}
afterFirst := getAPIConversationForTest(t, testDB, "session-history")
if components, ok := afterFirst.DSL["components"].(map[string]any); !ok || len(components) != 3 {
t.Fatalf("persisted DSL lost runtime components: %#v", afterFirst.DSL)
}
@@ -334,10 +332,7 @@ func TestRunAgent_SessionHistoryFeedsSysHistoryAndPersists(t *testing.T) {
t.Fatalf("sorted user history = %#v, want [user: again, user: hi]", secondHistory[1:])
}
var session entity.API4Conversation
if err := testDB.Where("id = ?", "session-history").First(&session).Error; err != nil {
t.Fatalf("reload session: %v", err)
}
session := getAPIConversationForTest(t, testDB, "session-history")
history, ok := session.DSL["history"].([]any)
if !ok || len(history) != 4 {
t.Fatalf("persisted history = %#v, want four user/assistant entries", session.DSL["history"])
@@ -406,10 +401,11 @@ func TestRunAgent_NewSessionPersistsHistoryForNextTurn(t *testing.T) {
t.Fatalf("first content = %q", firstMessages[0].Content)
}
var session entity.API4Conversation
if err := testDB.Where("dialog_id = ? AND user_id = ?", "canvas-new-session", "user-1").First(&session).Error; err != nil {
var sessionID string
if err := testDB.Model(&entity.API4Conversation{}).Where("dialog_id = ? AND user_id = ?", "canvas-new-session", "user-1").Pluck("id", &sessionID).Error; err != nil {
t.Fatalf("new session was not persisted: %v", err)
}
session := getAPIConversationForTest(t, testDB, sessionID)
if session.ID == "" {
t.Fatal("persisted session has an empty ID")
}
@@ -469,14 +465,14 @@ func TestRunAgent_RejectsSessionOwnedByAnotherUser(t *testing.T) {
}
makeCanvasWithDSL(t, "canvas-session-owner", "user-1", "tenant-1", "v-session-owner", dsl)
foreignMessage := json.RawMessage(`[{"role":"assistant","content":"foreign"}]`)
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-foreign",
DialogID: "canvas-session-owner",
UserID: "user-2",
Message: foreignMessage,
Reference: json.RawMessage(`[]`),
DSL: entity.JSONMap(dsl),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create foreign session: %v", err)
}
@@ -499,11 +495,8 @@ func TestRunAgent_RejectsSessionOwnedByAnotherUser(t *testing.T) {
t.Fatalf("error = %v, want not-found authorization sentinel", err)
}
var unchanged entity.API4Conversation
if err := testDB.Where("id = ?", "session-foreign").First(&unchanged).Error; err != nil {
t.Fatalf("reload foreign session: %v", err)
}
if string(unchanged.Message) != string(foreignMessage) {
unchanged := getAPIConversationForTest(t, testDB, "session-foreign")
if !reflect.DeepEqual(parseMessages(unchanged.Message), parseMessages(foreignMessage)) {
t.Fatalf("foreign session message was overwritten: %s", unchanged.Message)
}
}
@@ -570,14 +563,14 @@ func TestRunAgent_RealCanvas_WaitForUserResume(t *testing.T) {
"path": []any{"begin_0", "user_fill_up_0", "message_0"},
}
makeCanvasWithDSL(t, "canvas-fillup", "user-1", "tenant-1", "v-fillup", dsl)
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-fillup",
DialogID: "canvas-fillup",
UserID: "user-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
DSL: entity.JSONMap(dsl),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create session: %v", err)
}
@@ -626,10 +619,7 @@ func TestRunAgent_RealCanvas_WaitForUserResume(t *testing.T) {
if waiting1[0].CpnID == "" {
t.Error("run 1: waiting_for_user event has empty cpn_id")
}
var interrupted entity.API4Conversation
if err := testDB.Where("id = ?", "session-fillup").First(&interrupted).Error; err != nil {
t.Fatalf("run 1: reload session: %v", err)
}
interrupted := getAPIConversationForTest(t, testDB, "session-fillup")
interruptedHistory, _ := interrupted.DSL["history"].([]any)
if len(interruptedHistory) != 1 {
t.Fatalf("run 1: persisted history = %#v, want only the user turn", interrupted.DSL["history"])
@@ -669,10 +659,7 @@ func TestRunAgent_RealCanvas_WaitForUserResume(t *testing.T) {
if !strings.Contains(messages2[0].Content, "got: my follow-up") {
t.Errorf("run 2: Content = %q, want substring %q", messages2[0].Content, "got: my follow-up")
}
var completed entity.API4Conversation
if err := testDB.Where("id = ?", "session-fillup").First(&completed).Error; err != nil {
t.Fatalf("run 2: reload session: %v", err)
}
completed := getAPIConversationForTest(t, testDB, "session-fillup")
completedHistory, _ := completed.DSL["history"].([]any)
if len(completedHistory) != 3 {
t.Fatalf("run 2: persisted history = %#v, want two user turns and one assistant turn", completed.DSL["history"])
@@ -713,14 +700,14 @@ func TestRunAgent_InterruptPersistsPartialAssistantHistory(t *testing.T) {
"path": []any{"begin_0", "prompt_0", "user_fill_up_0"},
}
makeCanvasWithDSL(t, "canvas-partial", "user-1", "tenant-1", "v-partial", dsl)
if err := testDB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), testDB, &entity.API4Conversation{
ID: "session-partial",
DialogID: "canvas-partial",
UserID: "user-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
DSL: entity.JSONMap(dsl),
}).Error; err != nil {
}); err != nil {
t.Fatalf("create session: %v", err)
}
@@ -744,10 +731,7 @@ func TestRunAgent_InterruptPersistsPartialAssistantHistory(t *testing.T) {
t.Fatalf("partial messages = %+v", messages)
}
var session entity.API4Conversation
if err := testDB.Where("id = ?", "session-partial").First(&session).Error; err != nil {
t.Fatalf("reload session: %v", err)
}
session := getAPIConversationForTest(t, testDB, "session-partial")
history, ok := session.DSL["history"].([]any)
if !ok || len(history) != 2 {
t.Fatalf("persisted history = %#v, want user and partial assistant", session.DSL["history"])
+8 -4
View File
@@ -50,6 +50,7 @@ type ListAgentSessionsRequest struct {
Desc bool
ExpUserID string
IncludeDSL bool
NoHistory bool
}
// ListAgentSessionsResponse is the response body for ListAgentSessions.
@@ -359,6 +360,7 @@ func (s *AgentService) ListAgentSessions(ctx context.Context, userID, _ string,
SessionID: req.SessionID,
UserID: req.UserID,
IncludeDSL: req.IncludeDSL,
NoHistory: req.NoHistory,
Keywords: req.Keywords,
FromDate: fromDate,
ToDate: toDate,
@@ -371,6 +373,9 @@ func (s *AgentService) ListAgentSessions(ctx context.Context, userID, _ string,
data := make([]map[string]interface{}, 0, len(sessions))
for _, session := range sessions {
data = append(data, normalizeAgentSession(session, req.IncludeDSL))
if req.NoHistory {
delete(data[len(data)-1], "message")
}
}
return &ListAgentSessionsResponse{Data: data, Total: total}, common.CodeSuccess, nil
}
@@ -701,9 +706,8 @@ type CreateAgentSessionRequest struct {
// - id : 32-hex uuid, matches Python uuid.uuid4().hex
// - dialog_id : agent canvas id
// - user_id : caller's id
// - message : JSON array (default []); GET path normalizes it
// - reference : JSON object (default {}) so GET-side parsing
// does not crash on .chunks
// - message : ordered rows in api_4_conversation_message
// - reference : ordered rows in api_4_conversation_reference
// - dsl : JSON map; copied from user_canvas.dsl if the
// caller did not pass one
// - create_time : unix-millis
@@ -736,7 +740,7 @@ func (s *AgentService) CreateAgentSession(ctx context.Context, req *CreateAgentS
if len(messages) == 0 {
messages = json.RawMessage(`[]`)
}
reference := json.RawMessage(`{}`)
reference := json.RawMessage(`[]`)
var dsl entity.JSONMap
if len(req.DSL) > 0 {
+2 -2
View File
@@ -880,7 +880,7 @@ func createAgentSessionTestCanvas(t *testing.T, id, userID string) {
func createAgentSessionTestConversation(t *testing.T, id, agentID, userID string, updateTime int64) {
t.Helper()
updateDate := time.UnixMilli(updateTime)
if err := dao.DB.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), dao.DB, &entity.API4Conversation{
ID: id,
DialogID: agentID,
UserID: userID,
@@ -892,7 +892,7 @@ func createAgentSessionTestConversation(t *testing.T, id, agentID, userID string
UpdateTime: ptr(updateTime),
UpdateDate: &updateDate,
},
}).Error; err != nil {
}); err != nil {
t.Fatalf("failed to create session %s: %v", id, err)
}
}
+19 -38
View File
@@ -27,8 +27,6 @@ import (
"encoding/json"
"errors"
"fmt"
"hash/fnv"
"sync"
"ragflow/internal/agent/canvas"
"ragflow/internal/agent/dsl"
@@ -51,14 +49,6 @@ type BotService struct {
agentService *AgentService
llmService *LLMService
pipeline *ChatPipelineService
// persistLocks serialises persistChatbotTurn's read-modify-write
// on a single api_4_conversation row. ChatbotCompletion fetches
// the session before streaming starts, so without this lock two
// concurrent requests on the same session_id would each append
// their turn to the same stale base and the last Update would
// silently drop the other exchange. Striped to a fixed size so
// the lock set does not grow with the number of sessions.
persistLocks [64]sync.Mutex
}
// NewBotService wires a fresh BotService. agentSvc is required for
@@ -151,6 +141,11 @@ func (s *BotService) AgentbotInputs(ctx context.Context, tenantID, agentID strin
func (s *BotService) AgentbotCompletion(
ctx context.Context, tenantID, agentID string, req AgentbotCompletionRequest,
) (<-chan canvas.RunEvent, common.ErrorCode, error) {
question, err := ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
return nil, common.CodeArgumentError, err
}
req.Question = question
if s.agentService == nil {
return nil, common.CodeServerError, fmt.Errorf("bot: agent service not wired")
}
@@ -202,9 +197,10 @@ func (s *BotService) AgentbotLogs(ctx context.Context, tenantID, agentID, messag
// accepts; the URL-bound agent_id is the authoritative canvas id
// (matches python bot_api.py:159).
type AgentbotCompletionRequest struct {
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Stream bool `json:"stream"`
Messages []map[string]interface{} `json:"messages,omitempty"`
SessionID string `json:"session_id"`
UserID string `json:"user_id"`
Stream bool `json:"stream"`
// Query is the free-text chat question. The shared/embedded chat
// page sends `query` (the Python completion reads it before
// `question`).
@@ -216,19 +212,12 @@ type AgentbotCompletionRequest struct {
Files []map[string]interface{} `json:"files"`
}
// agentbotUserInput derives the single user-input value RunAgent
// expects from an AgentbotCompletionRequest. Mirrors the Python
// `query = kwargs.get("query", "") or kwargs.get("question", "")`
// precedence in canvas_service.completion, and the in-app chat
// handler's form-input fallback (handler/agent.go
// extractUserInputFromFormInputs): when neither query field is set,
// the begin-form `inputs` map supplies the value — a single field
// lifts its `value` entry, multiple fields collapse to a
// name→value map.
// agentbotUserInput uses the resolved question, falling back to Begin form inputs.
// A single form field lifts its value; multiple fields collapse to a name/value map.
func agentbotUserInput(req AgentbotCompletionRequest) any {
query := req.Query
query := req.Question
if query == "" {
query = req.Question
query = req.Query
}
if query != "" {
return query
@@ -265,10 +254,12 @@ func agentbotUserInput(req AgentbotCompletionRequest) any {
// `async_iframe_completion` body shape (session_id, question,
// tts (unused) and a freeform dict).
type ChatbotCompletionRequest struct {
SessionID string `json:"session_id"`
Question string `json:"question"`
Stream bool `json:"stream"`
Inputs map[string]any `json:"inputs"`
Query string `json:"query,omitempty"`
Messages []map[string]interface{} `json:"messages,omitempty"`
SessionID string `json:"session_id"`
Question string `json:"question"`
Stream bool `json:"stream"`
Inputs map[string]any `json:"inputs"`
// Quote controls citation generation. Nil means "absent" —
// python bot_api.py defaults it to False for chatbot
// completions, so the service layer mirrors that.
@@ -285,16 +276,6 @@ type ChatbotCompletionRequest struct {
DocIDs string `json:"doc_ids"`
}
// persistLock returns the striped mutex guarding one session row's
// read-modify-write in persistChatbotTurn. The modulo runs on the
// unsigned hash — converting to int first would go negative on
// 32-bit architectures and panic with an out-of-bounds index.
func (s *BotService) persistLock(sessionID string) *sync.Mutex {
h := fnv.New32a()
_, _ = h.Write([]byte(sessionID))
return &s.persistLocks[h.Sum32()%uint32(len(s.persistLocks))]
}
// loadCanvas is the IDOR guard for agentbot reads. It mirrors the
// private loadCanvasForUser helper on AgentService without taking a
// dependency on the agentService pointer (so BotService can be
+56 -118
View File
@@ -296,6 +296,12 @@ func writeSSEJSON(w http.ResponseWriter, payload map[string]any) error {
func (s *BotService) ChatbotCompletion(
ctx context.Context, tenantID, dialogID string, req ChatbotCompletionRequest,
) (<-chan ChatbotSSEFrame, common.ErrorCode, error) {
receivedAt := float64(time.Now().UnixNano()) / 1e9
question, err := ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
return nil, common.CodeArgumentError, err
}
req.Question = question
// 1. Load and authorise the dialog.
//
// ChatSessionDAO.GetDialogByID already filters by status = "1"
@@ -330,8 +336,9 @@ func (s *BotService) ChatbotCompletion(
// behaviour and add the comment so a future reader doesn't
// "fix" it to a tenant-id lookup and break the symmetry.
if req.SessionID == "" {
// No session yet: seed one with the prologue and return it
// immediately WITHOUT running the pipeline. Mirrors python
// Seed a new session. An empty question is the opening handshake;
// a supplied question continues through the normal generation path.
// Mirrors python
// async_iframe_completion (conversation_service.py:324-334):
// the share page calls this endpoint once with an empty
// question to obtain a session_id, then sends the real
@@ -354,24 +361,27 @@ func (s *BotService) ChatbotCompletion(
return nil, common.CodeServerError, err
}
// Mirror python async_iframe_completion
// (conversation_service.py:324-334): a request without a
// session_id is the share page's opening handshake — the
// front-end sends an empty question only to obtain a session.
// Persist the prologue-seeded session and stream the prologue
// back WITHOUT invoking the pipeline; running the model here
// would fabricate a reply to a message the user never sent.
out := make(chan ChatbotSSEFrame, 2)
go func() {
defer close(out)
out <- ChatbotSSEFrame{
Data: prologue,
Reference: map[string]any{},
SessionID: session.ID,
}
out <- ChatbotSSEFrame{Done: true}
}()
return out, common.CodeSuccess, nil
req.SessionID = session.ID
if req.Question == "" {
// Mirror python async_iframe_completion
// (conversation_service.py:324-334): a request without a
// session_id is the share page's opening handshake — the
// front-end sends an empty question only to obtain a session.
// Persist the prologue-seeded session and stream the prologue
// back WITHOUT invoking the pipeline; running the model here
// would fabricate a reply to a message the user never sent.
out := make(chan ChatbotSSEFrame, 2)
go func() {
defer close(out)
out <- ChatbotSSEFrame{
Data: prologue,
Reference: map[string]any{},
SessionID: session.ID,
}
out <- ChatbotSSEFrame{Done: true}
}()
return out, common.CodeSuccess, nil
}
}
session, err := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, req.SessionID, dialogID)
@@ -398,8 +408,7 @@ func (s *BotService) ChatbotCompletion(
return nil, common.CodeDataError, errors.New("no LLM configured for this chatbot")
}
// 4. Build the pipeline input. The Message column on
// api_4_conversation is a json.RawMessage array of
// 4. Build the pipeline input. The hydrated Message value is a JSON array of
// {role, content, created_at} dicts; the pipeline expects the
// same filtered shape python builds in async_iframe_completion
// (drop system turns, drop the leading assistant prologue,
@@ -430,6 +439,9 @@ func (s *BotService) ChatbotCompletion(
kwargs["doc_ids"] = req.DocIDs
}
if err := s.persistChatbotQuestion(ctx, session, req.Question, messageID, receivedAt); err != nil {
return nil, common.CodeServerError, err
}
results, err := s.pipeline.AsyncChat(ctx, tenantID, dialog, messages, true, kwargs)
if err != nil {
return nil, common.CodeServerError, err
@@ -483,9 +495,9 @@ func (s *BotService) streamChatbotTurn(
// no-delta fallback.
var rawAnswer, fullAnswer string
var finalRef map[string]any
var errored bool
for res := range results {
if res.Final {
completedAt := float64(time.Now().UnixNano()) / 1e9
if res.Answer != "" {
// Decorated full answer (citations
// resolved). Replaces the accumulated
@@ -498,8 +510,17 @@ func (s *BotService) streamChatbotTurn(
if res.Reference != nil {
finalRef = res.Reference
}
if strings.HasPrefix(fullAnswer, "**ERROR**") {
errored = true
errored := strings.HasPrefix(fullAnswer, "**ERROR**")
// Save the completed answer before delivering the final SSE frame.
// The question was saved before generation; failures leave it intact.
if !errored && ctx.Err() == nil {
persisted := rawAnswer
if persisted == "" {
persisted = fullAnswer
}
if pErr := s.persistChatbotTurn(ctx, session, question, persisted, messageID, finalRef, completedAt); pErr != nil {
common.Error("bot: ChatbotCompletion session update failed", pErr, zap.String("dialog_id", session.DialogID), zap.String("session_id", session.ID))
}
}
finalData := ""
if rawAnswer == "" || errored {
@@ -570,36 +591,6 @@ func (s *BotService) streamChatbotTurn(
}
}
// 6. Persist the completed turn pair (user + assistant)
// plus the retrieval reference, mirroring python
// API4ConversationService.append_message after the stream.
// Persistence errors are logged but do NOT fail the SSE
// stream — the answer has already been produced. On a
// pipeline-level error ("**ERROR**" answer) nothing is
// persisted, matching the python exception path.
persisted := rawAnswer
if persisted == "" {
// No-delta finals never carry server-inserted [ID:n]
// citation markers: the structured-SQL path returns its
// markdown table (with ##N$$ source markers the frontend
// resolves client-side, same as python use_sql) before
// decorateAnswer runs, the empty_response fallback yields
// the user-configured text undecorated, and the streaming
// path always streams the full visible text as deltas
// before its decorated final (so rawAnswer is non-empty
// whenever the final was decorated). Falling back to
// fullAnswer here is therefore marker-free.
persisted = fullAnswer
}
if !errored {
if pErr := s.persistChatbotTurn(ctx, session, question, persisted, messageID, finalRef); pErr != nil {
common.Error("bot: ChatbotCompletion session update failed",
pErr,
zap.String("dialog_id", session.DialogID),
zap.String("session_id", session.ID),
)
}
}
out <- ChatbotSSEFrame{Done: true}
}()
return out
@@ -648,75 +639,22 @@ func parseChatbotTurns(raw json.RawMessage) []map[string]any {
return turns
}
// persistChatbotTurn appends the finished user/assistant turn pair
// and the retrieval reference to the api_4_conversation row so the
// next ChatbotCompletion call with the same session_id sees this
// turn in its history. Mirrors python
// API4ConversationService.append_message.
func (s *BotService) persistChatbotQuestion(ctx context.Context, session *entity.API4Conversation, question, messageID string, receivedAt float64) error {
message := map[string]interface{}{"role": "user", "content": question, "id": messageID, "created_at": receivedAt}
return s.api4ConversationDAO.UpdateHistory(ctx, dao.DB, session.ID, session.DialogID, session.UserID, nil, dao.ConversationHistoryUpdate{Message: message})
}
// persistChatbotTurn completes an already-persisted user turn with its
// assistant message and immutable retrieval reference.
func (s *BotService) persistChatbotTurn(
ctx context.Context, session *entity.API4Conversation, question, answer, messageID string, reference map[string]any,
completedAt float64,
) error {
// Serialise the read-modify-write per session and re-read the row
// inside the lock: the caller's session was loaded before the
// stream ran, so a concurrent request on the same session_id may
// already have appended its own turn. Without the lock + re-read
// the last Update would silently drop the other exchange.
lock := s.persistLock(session.ID)
lock.Lock()
defer lock.Unlock()
fresh, err := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, session.ID, session.DialogID)
if err != nil {
return err
}
if fresh != nil {
session = fresh
}
now := time.Now().Unix()
turns := parseChatbotTurns(session.Message)
// Both turns of the pair share messageID by design: a Q&A exchange
// is addressed as a unit — mirrors the in-app chat convention
// where the answer id is derived from the question id so the pair
// is deleted together (web/src/hooks/logic-hooks.ts
// buildMessageUuid).
turns = append(turns,
map[string]any{
"role": "user",
"content": question,
"id": messageID,
"created_at": now,
},
map[string]any{
"role": "assistant",
"content": answer,
"id": messageID,
"created_at": now,
},
)
rawMsg, err := json.Marshal(turns)
if err != nil {
return err
}
session.Message = rawMsg
refs := make([]any, 0)
if len(session.Reference) > 0 {
// Tolerate malformed / missing reference history — the
// message turns above are the authoritative history;
// a lost reference list degrades citation display only.
_ = json.Unmarshal(session.Reference, &refs)
}
if reference == nil {
reference = map[string]any{"chunks": []any{}, "doc_aggs": []any{}}
}
refs = append(refs, reference)
rawRef, err := json.Marshal(refs)
if err != nil {
return err
}
session.Reference = rawRef
return s.api4ConversationDAO.Update(ctx, dao.DB, session)
message := map[string]interface{}{"role": "assistant", "content": answer, "id": messageID, "created_at": completedAt}
return s.api4ConversationDAO.UpdateHistory(ctx, dao.DB, session.ID, session.DialogID, session.UserID, nil, dao.ConversationHistoryUpdate{Message: message, QuestionID: messageID, Reference: reference, AppendReference: true})
}
// normalizeBotBoolFlag coerces the JSON-encoded reasoning / internet
+39 -15
View File
@@ -514,7 +514,17 @@ func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
"chunks": []any{map[string]any{"chunk_id": "c1"}},
"doc_aggs": []any{},
}
if err := svc.persistChatbotTurn(ctx, sess, "What is Go?", "A language.", "msg-p1", ref); err != nil {
if err := svc.persistChatbotQuestion(ctx, sess, "What is Go?", "msg-p1", 2); err != nil {
t.Fatalf("persist question: %v", err)
}
questionRow, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p1", "dlg-p1")
if err != nil || questionRow == nil {
t.Fatalf("read pending question: %v", err)
}
if turns := parseChatbotTurns(questionRow.Message); len(turns) != 2 || turns[1]["role"] != "user" || turns[1]["created_at"] != float64(2) {
t.Fatalf("question must be persisted before completion: %+v", turns)
}
if err := svc.persistChatbotTurn(ctx, sess, "What is Go?", "A language.", "msg-p1", ref, 3); err != nil {
t.Fatalf("persistChatbotTurn: %v", err)
}
@@ -534,6 +544,9 @@ func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
if turns[2]["role"] != "assistant" || turns[2]["content"] != "A language." || turns[2]["id"] != "msg-p1" {
t.Errorf("assistant turn: %+v", turns[2])
}
if turns[1]["created_at"] != float64(2) || turns[2]["created_at"] != float64(3) {
t.Fatalf("question and answer timestamps must remain separate: %+v", turns)
}
var refs []map[string]any
if err := json.Unmarshal(row.Reference, &refs); err != nil {
@@ -562,7 +575,10 @@ func TestPersistChatbotTurn_NilReferenceDefaultsToEmpty(t *testing.T) {
}
svc := NewBotService(nil, nil)
if err := svc.persistChatbotTurn(ctx, sess, "q", "a", "msg-p2", nil); err != nil {
if err := svc.persistChatbotQuestion(ctx, sess, "q", "msg-p2", 2); err != nil {
t.Fatalf("persist question: %v", err)
}
if err := svc.persistChatbotTurn(ctx, sess, "q", "a", "msg-p2", nil, 3); err != nil {
t.Fatalf("persistChatbotTurn: %v", err)
}
@@ -615,7 +631,11 @@ func TestPersistChatbotTurn_ConcurrentSameSession(t *testing.T) {
wg.Add(1)
go func(i int, q, a string) {
defer wg.Done()
if err := svc.persistChatbotTurn(ctx, sess, q, a, fmt.Sprintf("msg-p3-%d", i), nil); err != nil {
if err := svc.persistChatbotQuestion(ctx, sess, q, fmt.Sprintf("msg-p3-%d", i), 2); err != nil {
t.Errorf("persist question %d: %v", i, err)
return
}
if err := svc.persistChatbotTurn(ctx, sess, q, a, fmt.Sprintf("msg-p3-%d", i), nil, 3); err != nil {
t.Errorf("persistChatbotTurn %d: %v", i, err)
}
}(i, turn[0], turn[1])
@@ -656,9 +676,13 @@ func (r *recordingResponseWriter) WriteHeader(_ int) {}
// seedStreamTurnSession creates a persisted api_4_conversation row the
// streamChatbotTurn tests can append to.
func seedStreamTurnSession(t *testing.T, id string) {
func seedStreamTurnSession(t *testing.T, id, question, messageID string) {
t.Helper()
sess := &entity.API4Conversation{ID: id, DialogID: "dlg-s1", UserID: "tenant-1"}
raw, err := json.Marshal([]map[string]any{{"role": "user", "content": question, "id": messageID, "created_at": 1}})
if err != nil {
t.Fatal(err)
}
sess := &entity.API4Conversation{ID: id, DialogID: "dlg-s1", UserID: "tenant-1", Message: raw}
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), dao.DB, sess); err != nil {
t.Fatalf("seed session: %v", err)
}
@@ -681,7 +705,7 @@ func TestStreamChatbotTurn_FinalFrameAndPersistenceUseRawStreamText(t *testing.T
db := setupServiceTestDB(t)
pushServiceDB(t, db)
seedStreamTurnSession(t, "sess-s1")
seedStreamTurnSession(t, "sess-s1", "q1", "msg-s1")
svc := NewBotService(nil, nil)
ref := map[string]any{
@@ -739,7 +763,7 @@ func TestStreamChatbotTurn_ThinkMarkersPersistedAsTags(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
seedStreamTurnSession(t, "sess-s2")
seedStreamTurnSession(t, "sess-s2", "q2", "msg-s2")
svc := NewBotService(nil, nil)
results := make(chan AsyncChatResult, 5)
@@ -775,7 +799,7 @@ func TestStreamChatbotTurn_SingleShotFinalKeepsText(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
seedStreamTurnSession(t, "sess-s3")
seedStreamTurnSession(t, "sess-s3", "q3", "msg-s3")
svc := NewBotService(nil, nil)
ref := map[string]any{"chunks": []any{}, "doc_aggs": []any{}}
@@ -812,7 +836,7 @@ func TestStreamChatbotTurn_ErrorKeptOnWireAndNotPersisted(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
seedStreamTurnSession(t, "sess-s4")
seedStreamTurnSession(t, "sess-s4", "q4", "msg-s4")
svc := NewBotService(nil, nil)
results := make(chan AsyncChatResult, 1)
@@ -835,8 +859,8 @@ func TestStreamChatbotTurn_ErrorKeptOnWireAndNotPersisted(t *testing.T) {
if err != nil || row == nil {
t.Fatalf("re-read session: row=%v err=%v", row, err)
}
if turns := parseChatbotTurns(row.Message); len(turns) != 0 {
t.Errorf("error turns must not persist, got %+v", turns)
if turns := parseChatbotTurns(row.Message); len(turns) != 1 || turns[0]["role"] != "user" {
t.Errorf("error must retain only the user question, got %+v", turns)
}
}
@@ -850,7 +874,7 @@ func TestStreamChatbotTurn_ErrorAfterDeltasKeptOnWire(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
seedStreamTurnSession(t, "sess-s5")
seedStreamTurnSession(t, "sess-s5", "q5", "msg-s5")
svc := NewBotService(nil, nil)
results := make(chan AsyncChatResult, 2)
@@ -877,8 +901,8 @@ func TestStreamChatbotTurn_ErrorAfterDeltasKeptOnWire(t *testing.T) {
if err != nil || row == nil {
t.Fatalf("re-read session: row=%v err=%v", row, err)
}
if turns := parseChatbotTurns(row.Message); len(turns) != 0 {
t.Errorf("error turns must not persist, got %+v", turns)
if turns := parseChatbotTurns(row.Message); len(turns) != 1 || turns[0]["role"] != "user" {
t.Errorf("error must retain only the user question, got %+v", turns)
}
}
@@ -894,7 +918,7 @@ func TestStreamChatbotTurn_ReasoningFieldForwardedAsStreamText(t *testing.T) {
db := setupServiceTestDB(t)
pushServiceDB(t, db)
seedStreamTurnSession(t, "sess-s6")
seedStreamTurnSession(t, "sess-s6", "q6", "msg-s6")
svc := NewBotService(nil, nil)
results := make(chan AsyncChatResult, 5)
-2
View File
@@ -321,8 +321,6 @@ func (s *ChatChannelService) HandleIncomingMessage(ctx context.Context, msg Chat
nil,
map[string]interface{}{"quote": false},
false,
true,
false,
false,
nil,
)
+12 -6
View File
@@ -153,7 +153,7 @@ type AsyncChatResult struct {
//
// Parameters:
// - chat: the chat/chat entity with KBs, prompt_config, etc.
// - messages: pre-filtered user/assistant messages (system already stripped).
// - messages: saved user/assistant history, or the full payload for non-storing model tests.
// - stream: if true, yields content deltas as they arrive.
// - kwargs: extra parameters (doc_ids, knowledge, quote, etc.).
func (s *ChatPipelineService) AsyncChat(
@@ -761,7 +761,11 @@ func (s *ChatPipelineService) AsyncChat(
}
harnessSystemPrompt = s.formatPrompt(sp, kws)
}
hk, slotCites, harnessAnswer, hErr := s.retrieveViaHarness(ctx, question, kbs, docIDs, imageFiles, attachments, thinkingMode, chat.TenantID, chat.LLMID, chat.ID, webSearch, sink, harnessSystemPrompt)
var history []map[string]interface{}
if kwargs["store_history_messages"] == false {
history = messages
}
hk, slotCites, harnessAnswer, hErr := s.retrieveViaHarness(ctx, question, kbs, docIDs, imageFiles, attachments, thinkingMode, chat.TenantID, chat.LLMID, chat.ID, webSearch, sink, harnessSystemPrompt, history)
// The harness streams think-then-answer inside ONE compose call.
// Close the block here, once that call (and its trailing
// narration line) has returned: a reasoning-only run would
@@ -1038,7 +1042,7 @@ func (s *ChatPipelineService) AsyncChat(
}
for _, m := range messages {
role, _ := m["role"].(string)
if role == "system" {
if role == "system" && kwargs["store_history_messages"] != false {
continue
}
llmMessage := normalizeLLMMessage(m)
@@ -1485,11 +1489,11 @@ func (s *ChatPipelineService) AsyncChatSolo(
}
}
// 3. Strip citation markers and drop system messages from history.
// 3. Strip citation markers; non-storing model tests retain payload system messages.
var msg []map[string]interface{}
for _, m := range messages {
role, _ := m["role"].(string)
if role == "system" {
if role == "system" && config["store_history_messages"] != false {
continue
}
llmMessage := normalizeLLMMessage(m)
@@ -4699,6 +4703,7 @@ func harnessBoundDatasetNames(kbs []*entity.Knowledgebase) string {
type HarnessRequest struct {
Question string
DatasetIDs []string
Messages []map[string]interface{}
// DocIDs scopes the agentic search to these document ids (Python
// dialog_service.py doc_scope). chat_pipeline folds the chat-level
// meta_data_filter into docIDs before calling, so forwarding this alone
@@ -4807,7 +4812,7 @@ func toolLoopLine(sink func(delta string, isThink bool), line string) {
sink(line+thinkLineBreak, true)
}
func (s *ChatPipelineService) retrieveViaHarness(ctx context.Context, question string, kbs []*entity.Knowledgebase, docIDs []string, images []string, textAttachments string, thinkingMode, tenantID, modelID, sessionID string, webSearch func(context.Context, []string) ([]string, error), answerSink func(delta string, isThink bool), dialogSystemPrompt string) (map[string]interface{}, map[string][]string, string, error) {
func (s *ChatPipelineService) retrieveViaHarness(ctx context.Context, question string, kbs []*entity.Knowledgebase, docIDs []string, images []string, textAttachments string, thinkingMode, tenantID, modelID, sessionID string, webSearch func(context.Context, []string) ([]string, error), answerSink func(delta string, isThink bool), dialogSystemPrompt string, messages []map[string]interface{}) (map[string]interface{}, map[string][]string, string, error) {
if harnessRetriever == nil {
return nil, nil, "", fmt.Errorf("harness retriever not wired at bootstrap")
}
@@ -4821,6 +4826,7 @@ func (s *ChatPipelineService) retrieveViaHarness(ctx context.Context, question s
}
res, err := harnessRetriever(ctx, HarnessRequest{
Question: question,
Messages: messages,
DatasetIDs: kbIDs,
DocIDs: docIDs,
ThinkingMode: thinkingMode,
+13 -3
View File
@@ -1565,14 +1565,24 @@ func collectSink(got *[]string, thinks *[]bool) func(string, bool) {
// "rag.llm.chat_model" namespace.
func TestRetrieveViaHarnessEmitsToolLoopLines(t *testing.T) {
stubHarness(t, "the final cited answer")
var receivedHistory []map[string]interface{}
retriever := harnessRetriever
harnessRetriever = func(ctx context.Context, req HarnessRequest) (HarnessResult, error) {
receivedHistory = req.Messages
return retriever(ctx, req)
}
history := []map[string]interface{}{{"role": "user", "content": "earlier question"}, {"role": "assistant", "content": "earlier answer"}, {"role": "user", "content": "q"}}
var got []string
var thinks []bool
s := &ChatPipelineService{}
_, _, answer, err := s.retrieveViaHarness(context.Background(), "q", nil, nil, nil, "", "high", "t", "m", "sess", nil, collectSink(&got, &thinks), "")
_, _, answer, err := s.retrieveViaHarness(t.Context(), "q", nil, nil, nil, "", "high", "t", "m", "sess", nil, collectSink(&got, &thinks), "", history)
if err != nil {
t.Fatalf("retrieveViaHarness: %v", err)
}
if !reflect.DeepEqual(receivedHistory, history) {
t.Fatalf("harness history = %#v, want %#v", receivedHistory, history)
}
if answer != "the final cited answer" {
t.Fatalf("answer = %q", answer)
}
@@ -1611,7 +1621,7 @@ func TestRetrieveViaHarnessToolLoopObservation(t *testing.T) {
var got []string
var thinks []bool
s := &ChatPipelineService{}
if _, _, _, err := s.retrieveViaHarness(context.Background(), "q", nil, nil, nil, "", "high", "t", "m", "sess", nil, collectSink(&got, &thinks), ""); err != nil {
if _, _, _, err := s.retrieveViaHarness(t.Context(), "q", nil, nil, nil, "", "high", "t", "m", "sess", nil, collectSink(&got, &thinks), "", nil); err != nil {
t.Fatalf("retrieveViaHarness: %v", err)
}
joined := strings.Join(got, "")
@@ -1631,7 +1641,7 @@ func TestRetrieveViaHarnessNaiveSkipsToolLoop(t *testing.T) {
var got []string
var thinks []bool
s := &ChatPipelineService{}
if _, _, _, err := s.retrieveViaHarness(context.Background(), "q", nil, nil, nil, "", "naive", "t", "m", "sess", nil, collectSink(&got, &thinks), ""); err != nil {
if _, _, _, err := s.retrieveViaHarness(t.Context(), "q", nil, nil, nil, "", "naive", "t", "m", "sess", nil, collectSink(&got, &thinks), "", nil); err != nil {
t.Fatalf("retrieveViaHarness: %v", err)
}
if len(got) != 0 {
+133 -301
View File
@@ -47,7 +47,7 @@ type chatSessionStore interface {
Create(ctx context.Context, db *gorm.DB, conv *entity.ChatSession) error
UpdateByID(ctx context.Context, db *gorm.DB, id string, updates map[string]interface{}) error
DeleteByID(ctx context.Context, db *gorm.DB, id string) error
ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name, orderby string, desc bool, page, pageSize int) ([]*entity.ChatSession, error)
ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name, orderby string, desc bool, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error)
GetDialogByID(ctx context.Context, db *gorm.DB, chatID string) (*entity.Chat, error)
CheckDialogExists(ctx context.Context, db *gorm.DB, tenantID, chatID string) (bool, error)
}
@@ -154,8 +154,9 @@ func (s *ChatSessionService) SetChatSession(ctx context.Context, userID string,
}
messagesJSON, _ := json.Marshal([]map[string]interface{}{
{
"role": "assistant",
"content": prologue,
"role": "assistant",
"content": prologue,
"created_at": float64(time.Now().Unix()),
},
})
referenceJSON, _ := json.Marshal([]interface{}{})
@@ -243,7 +244,7 @@ type ChatSessionPayload struct {
}
// ListChatSessions lists chat sessions for a dialog
func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatID, sessionID, name, orderby string, desc bool, page, pageSize int) (*ListChatSessionsResponse, error) {
func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatID, sessionID, name, orderby string, desc bool, page, pageSize int, includeHistory ...bool) (*ListChatSessionsResponse, error) {
// Get user's tenants
tenantIDs, err := s.userTenantDAO.GetTenantIDsByUserID(ctx, dao.DB, userID)
if err != nil {
@@ -284,7 +285,7 @@ func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatI
}
// List chat sessions
sessions, err := s.chatSessionDAO.ListByChatID(ctx, dao.DB, chatID, sessionID, name, orderby, desc, page, pageSize)
sessions, err := s.chatSessionDAO.ListByChatID(ctx, dao.DB, chatID, sessionID, name, orderby, desc, page, pageSize, includeHistory...)
if err != nil {
return nil, err
}
@@ -360,8 +361,9 @@ func (s *ChatSessionService) CreateSession(ctx context.Context, userID, chatID s
}
messagesJSON, _ := json.Marshal([]map[string]interface{}{
{
"role": "assistant",
"content": prologue,
"role": "assistant",
"content": prologue,
"created_at": float64(time.Now().Unix()),
},
})
@@ -405,7 +407,7 @@ func (s *ChatSessionService) DeleteSessions(ctx context.Context, userID, chatID
if !hasIDs || len(sessionIDs) == 0 {
deleteAll, _ := req["delete_all"].(bool)
if deleteAll {
sessions, err := s.chatSessionDAO.ListByChatID(ctx, dao.DB, chatID, "", "", "create_time", true, 0, -1)
sessions, err := s.chatSessionDAO.ListByChatID(ctx, dao.DB, chatID, "", "", "create_time", true, 0, -1, false)
if err != nil {
return nil, "", common.CodeServerError, err
}
@@ -673,17 +675,18 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
if msgID != stringValue(msg["id"]) {
continue
}
if i+1 >= len(messages) || stringValue(messages[i+1]["id"]) != msgID {
if stringValue(msg["role"]) != "user" {
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:]...)
end := i + 1
if end < len(messages) && stringValue(messages[end]["role"]) == "assistant" && stringValue(messages[end]["id"]) == msgID {
refIndex := sessionMessageReferenceIndex(messages, end)
if refIndex >= 0 && refIndex < len(references) {
references = append(references[:refIndex], references[refIndex+1:]...)
}
end++
}
messages = append(messages[:i], messages[end:]...)
break
}
@@ -696,8 +699,7 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
return nil, common.CodeServerError, err
}
if err = s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, map[string]interface{}{
"message": messageRaw,
"reference": referenceRaw,
"history_update": dao.ConversationHistoryUpdate{DeleteMessageID: msgID},
}); err != nil {
return nil, common.CodeServerError, err
}
@@ -799,7 +801,7 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
}
if messageIndex != -1 && applyChunkFeedback {
refIndex := (messageIndex - 1) / 2
refIndex := sessionMessageReferenceIndex(messages, messageIndex)
if refIndex >= 0 && refIndex < len(references) {
if reference, ok := references[refIndex].(map[string]interface{}); ok && len(reference) > 0 {
feedbackReference = reference
@@ -811,7 +813,13 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
if err != nil {
return nil, common.CodeServerError, err
}
if err = s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, map[string]interface{}{"message": messageRaw}); err != nil {
feedbackUpdate := map[string]interface{}{"thumb_up": thumbup}
if thumbup {
feedbackUpdate["feedback"] = nil
} else if messageIndex != -1 && messages[messageIndex]["feedback"] != nil {
feedbackUpdate["feedback"] = messages[messageIndex]["feedback"]
}
if err = s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, map[string]interface{}{"history_update": dao.ConversationHistoryUpdate{FeedbackMessageID: msgID, Feedback: feedbackUpdate}}); err != nil {
return nil, common.CodeServerError, err
}
session.Message = messageRaw
@@ -842,6 +850,28 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
return s.buildSessionPayload(session, nil, false), common.CodeSuccess, nil
}
// sessionMessageReferenceIndex counts assistant responses after the first user
// message, excluding the prologue and unanswered questions.
func sessionMessageReferenceIndex(messages []map[string]interface{}, messageIndex int) int {
refIndex := -1
hasUser := false
for i, msg := range messages {
role := stringValue(msg["role"])
if role == "user" {
hasUser = true
} else if role == "assistant" && hasUser {
refIndex++
}
if i == messageIndex {
if role == "assistant" && hasUser {
return refIndex
}
return -1
}
}
return -1
}
const (
upvoteWeightIncrement = 1
downvoteWeightDecrement = 1
@@ -1312,181 +1342,6 @@ func isChatSessionNotFound(err error) bool {
return errors.Is(err, gorm.ErrRecordNotFound)
}
// 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(ctx context.Context, 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(ctx, dao.DB, conversationID)
if err != nil {
return nil, errors.New("conversation not found")
}
dialog, err := s.chatSessionDAO.GetDialogByID(ctx, dao.DB, 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(ctx, 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{}{}
}
resultChan, err := s.pipeline.AsyncChat(context.Background(), userID, dialog, messages, false, kwargs)
if err != nil {
return nil, err
}
var answer strings.Builder
var finalRef map[string]interface{}
for result := range resultChan {
if result.Final && result.Answer != "" {
// The final event carries the complete (decorated) answer;
// it replaces any accumulated deltas rather than appending.
answer.Reset()
answer.WriteString(result.Answer)
} else 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(ctx, 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 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(ctx, dao.DB, 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(ctx, dao.DB, 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(ctx, 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{}{}
}
resultChan, err := s.pipeline.AsyncChat(ctx, userID, dialog, messages, true, kwargs)
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(ctx, session, sessionMessages, reference)
}
return nil
}
// 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.
@@ -1496,10 +1351,11 @@ func (s *ChatSessionService) ChatCompletions(
chatID string, sessionID string,
messages []map[string]interface{}, question string, files []interface{},
llmID string, genConfig map[string]interface{}, kwargs map[string]interface{},
passAllHistory bool, storeHistory bool, legacy bool,
legacy bool,
stream bool, streamChan chan<- string,
) (map[string]interface{}, error) {
receivedAt := float64(time.Now().UnixNano()) / 1e9
fail := func(err error) (map[string]interface{}, error) {
if stream && streamChan != nil {
s.sendSSEError(streamChan, err.Error())
@@ -1519,7 +1375,11 @@ func (s *ChatSessionService) ChatCompletions(
common.Info("ChatCompletions started")
// --- 1. Normalize messages ---
requestMessages, requestMsg, messageID, err := s.normalizeCompletionMessages(messages, question, files)
storeHistoryMessages, err := ResolveStoreHistoryMessages(kwargs)
if err != nil {
return fail(common.NewCodedError(common.CodeArgumentError, err.Error()))
}
requestMsg, messageID, err := s.normalizeCompletionMessages(messages, question, files, storeHistoryMessages)
if err != nil {
return fail(common.NewCodedError(common.CodeArgumentError, err.Error()))
}
@@ -1554,24 +1414,26 @@ func (s *ChatSessionService) ChatCompletions(
if dialog.TenantID != userID && (session.UserID == nil || *session.UserID != userID) {
return fail(common.NewCodedError(common.CodeAuthenticationError, errSharedSessionReadonly.Error()))
}
} else {
session, err = s.createSessionForCompletion(ctx, chatID, dialog, userID, storeHistory)
} else if storeHistoryMessages {
session, err = s.createSessionForCompletion(ctx, chatID, dialog, userID)
if err != nil {
return fail(err)
}
sessionID = session.ID
}
if passAllHistory {
session.Message, _ = json.Marshal(requestMessages)
if storeHistoryMessages {
session = s.appendSessionMessage(session, requestMsg, receivedAt)
requestMsg = s.filterSystemAndLeadingAssistant(session)
} else {
session = s.appendSessionMessage(session, requestMsg)
session = nil
}
requestMsg = s.filterSystemAndLeadingAssistant(session)
_ = messageID
} else {
dialog = s.buildDefaultCompletionDialog(userID)
if !stream {
if genConfig == nil {
genConfig = map[string]interface{}{}
}
genConfig["stream"] = false
}
}
@@ -1580,6 +1442,8 @@ func (s *ChatSessionService) ChatCompletions(
var reference []interface{}
if session != nil {
reference = s.initializeReference(session)
} else if !storeHistoryMessages {
reference = s.initializeReference(&entity.ChatSession{})
}
// --- 5. LLM override ---
@@ -1615,6 +1479,12 @@ func (s *ChatSessionService) ChatCompletions(
}
// --- 6. Run pipeline ---
if session != nil {
updates := map[string]interface{}{"history_update": dao.ConversationHistoryUpdate{Message: requestMsg[len(requestMsg)-1]}}
if err := s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, updates); err != nil {
return fail(err)
}
}
resultChan, err := s.pipeline.AsyncChat(ctx, userID, dialog, requestMsg, stream, kwargs)
if err != nil {
return fail(err)
@@ -1628,6 +1498,19 @@ func (s *ChatSessionService) ChatCompletions(
if result.Reference != nil && len(reference) > 0 {
reference[len(reference)-1] = result.Reference
}
if result.Final {
failed := strings.Contains(result.Answer, "**ERROR**")
if session != nil && !failed {
content := result.Answer
if content == "" {
content = fullAnswer.String()
}
s.appendAssistantToSession(session, content, messageID)
if ctx.Err() == nil {
s.updateSessionMessages(ctx, session, s.getSessionMessagesAsSlice(session), reference)
}
}
}
if legacy {
if result.Final {
@@ -1730,11 +1613,6 @@ func (s *ChatSessionService) ChatCompletions(
wrapper := sseWrapper{Code: 0, Message: "", Data: true}
sendOrCancel(fmt.Sprintf("data:%s\n\n", marshalJSONWithSpaces(wrapper)))
// Persist session state (matches Python's update_by_id after loop)
if session != nil && storeHistory {
s.updateSessionMessages(ctx, session, s.getSessionMessagesAsSlice(session), reference)
}
} else {
ans := accumulateNonStreamAnswer(resultChan)
if session != nil {
@@ -1742,7 +1620,7 @@ func (s *ChatSessionService) ChatCompletions(
if chatID != "" {
result["chat_id"] = chatID
}
if storeHistory {
if ctx.Err() == nil && !strings.Contains(stringValue(ans["answer"]), "**ERROR**") {
s.updateSessionMessages(ctx, session, s.getSessionMessagesAsSlice(session), reference)
}
return sanitizeJSONFloats(result).(map[string]interface{}), nil
@@ -1807,64 +1685,44 @@ func accumulateNonStreamAnswer(resultChan <-chan AsyncChatResult) map[string]int
return ans
}
// normalizeCompletionMessages mirrors Python _normalize_completion_messages.
// normalizeCompletionMessages uses the full payload only for non-storing model tests.
func (s *ChatSessionService) normalizeCompletionMessages(
messages []map[string]interface{}, question string, files []interface{},
) (requestMessages []map[string]interface{}, requestMsg []map[string]interface{}, messageID string, err error) {
if len(messages) == 0 {
messages []map[string]interface{}, question string, files []interface{}, storeHistoryMessages bool,
) (requestMsg []map[string]interface{}, messageID string, err error) {
if !storeHistoryMessages && len(messages) == 0 {
return nil, "", errors.New("`messages` is required when `store_history_messages` is false")
}
if storeHistoryMessages && (question != "" || len(messages) == 0) {
if question == "" {
return nil, nil, "", errors.New("required argument are missing: messages")
return 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
}
lastMessage := messages[len(messages)-1]
if lastMessage["role"] != "user" {
return nil, "", errors.New("the last content of this conversation is not from user")
}
if storeHistoryMessages {
messages = messages[len(messages)-1:]
}
// 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
for _, msg := range messages {
message := make(map[string]interface{}, len(msg))
for key, value := range msg {
message[key] = value
}
if role == "assistant" && len(requestMsg) == 0 {
continue
}
requestMsg = append(requestMsg, m)
requestMsg = append(requestMsg, message)
}
if len(requestMsg) == 0 {
return nil, nil, "", errors.New("`messages` must contain a user message")
}
lastRole, _ := requestMsg[len(requestMsg)-1]["role"].(string)
if lastRole != "user" {
return nil, nil, "", errors.New("the last content of this conversation is not from user")
}
// 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 {
message := requestMsg[len(requestMsg)-1]
messageID, _ = message["id"].(string)
if messageID == "" {
messageID = utility.GenerateToken()
lastUserMsg["id"] = messageID
for i := len(requestMessages) - 1; i >= 0; i-- {
if role, _ := requestMessages[i]["role"].(string); role == "user" {
requestMessages[i]["id"] = messageID
break
}
}
message["id"] = messageID
}
return requestMessages, requestMsg, messageID, nil
return requestMsg, messageID, nil
}
// checkDialogOwnership checks if the user owns the dialog.
@@ -1896,7 +1754,7 @@ func (s *ChatSessionService) buildDefaultCompletionDialog(tenantID string) *enti
}
}
func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, chatID string, dialog *entity.Chat, userID string, saveSession bool) (*entity.ChatSession, error) {
func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, chatID string, dialog *entity.Chat, userID string) (*entity.ChatSession, error) {
name := "New session"
prologue := "Hi! I'm your assistant. What can I do for you?"
@@ -1907,7 +1765,7 @@ func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, cha
}
msg := []map[string]interface{}{
{"role": "assistant", "content": prologue},
{"role": "assistant", "content": prologue, "created_at": float64(time.Now().Unix())},
}
msgJSON, _ := json.Marshal(msg)
refJSON, _ := json.Marshal([]interface{}{})
@@ -1919,11 +1777,6 @@ func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, cha
UserID: &userID,
Reference: refJSON,
}
if !saveSession {
// Multi-model comparison sends store_history_messages=false; the
// ephemeral session must not be persisted (mirrors Python).
return session, nil
}
session.ID = utility.GenerateUUID()
if err := s.chatSessionDAO.Create(ctx, dao.DB, session); err != nil {
return nil, err
@@ -1969,9 +1822,14 @@ func channelSessionID(dialogID, channelID, chatID string) string {
}
// 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 {
func (s *ChatSessionService) appendSessionMessage(session *entity.ChatSession, requestMsg []map[string]interface{}, receivedAt float64) *entity.ChatSession {
msgs := parseMessages(session.Message)
msgs = append(msgs, requestMsg[len(requestMsg)-1])
message := make(map[string]interface{}, len(requestMsg[len(requestMsg)-1])+1)
for k, v := range requestMsg[len(requestMsg)-1] {
message[k] = v
}
message["created_at"] = receivedAt
msgs = append(msgs, message)
session.Message, _ = json.Marshal(msgs)
return session
}
@@ -2000,13 +1858,13 @@ func (s *ChatSessionService) appendAssistantToSession(session *entity.ChatSessio
messages = append(messages, map[string]interface{}{
"role": "assistant",
"content": content,
"created_at": float64(time.Now().Unix()),
"created_at": float64(time.Now().UnixNano()) / 1e9,
"id": messageID,
})
} else {
lastIdx := len(messages) - 1
messages[lastIdx]["content"] = content
messages[lastIdx]["created_at"] = float64(time.Now().Unix())
messages[lastIdx]["created_at"] = float64(time.Now().UnixNano()) / 1e9
messages[lastIdx]["id"] = messageID
}
session.Message, _ = json.Marshal(messages)
@@ -2035,37 +1893,6 @@ func (s *ChatSessionService) sendSSEError(streamChan chan<- string, errMsg strin
// Helper methods
func (s *ChatSessionService) buildSessionMessages(session *entity.ChatSession, messages []map[string]interface{}) []map[string]interface{} {
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))
for k, v := range msg {
cloned[k] = v
}
sessionMessages = append(sessionMessages, cloned)
}
return sessionMessages
}
func (s *ChatSessionService) initializeReference(session *entity.ChatSession) []interface{} {
var reference []interface{}
if len(session.Reference) > 0 {
@@ -2269,10 +2096,15 @@ func (s *ChatSessionService) updateSessionMessages(ctx context.Context, session
return
}
updates := map[string]interface{}{
"message": messagesJSON,
"reference": referenceJSON,
if len(messages) == 0 || stringValue(messages[len(messages)-1]["role"]) != "assistant" {
return
}
message := messages[len(messages)-1]
var latestReference map[string]interface{}
if len(reference) > 0 {
latestReference, _ = reference[len(reference)-1].(map[string]interface{})
}
updates := map[string]interface{}{"history_update": dao.ConversationHistoryUpdate{Message: message, QuestionID: stringValue(message["id"]), Reference: latestReference, AppendReference: true}}
if err := s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, updates); err != nil {
common.Warn("updateSessionMessages: DAO update failed", zap.Error(err))
return
@@ -2317,7 +2149,7 @@ func (s *ChatSessionService) structureAnswerWithConv(session *entity.ChatSession
messages = append(messages, map[string]interface{}{
"role": "assistant",
"content": content,
"created_at": float64(time.Now().Unix()),
"created_at": float64(time.Now().UnixNano()) / 1e9,
"id": messageID,
})
} else {
@@ -2329,7 +2161,7 @@ func (s *ChatSessionService) structureAnswerWithConv(session *entity.ChatSession
existing, _ := lastMsg["content"].(string)
lastMsg["content"] = existing + content
}
lastMsg["created_at"] = float64(time.Now().Unix())
lastMsg["created_at"] = float64(time.Now().UnixNano()) / 1e9
lastMsg["id"] = messageID
messages[lastIdx] = lastMsg
}
+125 -261
View File
@@ -11,6 +11,7 @@ import (
"testing"
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/engine"
"ragflow/internal/entity"
modelModule "ragflow/internal/entity/models"
@@ -55,7 +56,8 @@ func (f *fakeSessionStore) GetByID(ctx context.Context, db *gorm.DB, id string)
if !ok {
return nil, gorm.ErrRecordNotFound
}
return s, nil
clone := *s
return &clone, nil
}
func (f *fakeSessionStore) GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string) (*entity.ChatSession, error) {
@@ -75,7 +77,8 @@ func (f *fakeSessionStore) Create(ctx context.Context, db *gorm.DB, conv *entity
if f.createErr != nil {
return f.createErr
}
f.sessions[conv.ID] = conv
clone := *conv
f.sessions[conv.ID] = &clone
f.createCalled = append(f.createCalled, conv)
return nil
}
@@ -90,6 +93,48 @@ func (f *fakeSessionStore) UpdateByID(ctx context.Context, db *gorm.DB, id strin
if !ok {
return gorm.ErrRecordNotFound
}
if history, ok := updates["history_update"].(dao.ConversationHistoryUpdate); ok {
messages := parseMessages(s.Message)
references := parseReferenceList(s.Reference)
if history.DeleteMessageID != "" {
for i, message := range messages {
if stringValue(message["id"]) != history.DeleteMessageID {
continue
}
end := i + 1
if end < len(messages) && stringValue(messages[end]["role"]) == "assistant" && stringValue(messages[end]["id"]) == history.DeleteMessageID {
refIndex := sessionMessageReferenceIndex(messages, end)
if refIndex >= 0 && refIndex < len(references) {
references = append(references[:refIndex], references[refIndex+1:]...)
}
end++
}
messages = append(messages[:i], messages[end:]...)
break
}
} else if history.FeedbackMessageID != "" {
for _, message := range messages {
if stringValue(message["id"]) == history.FeedbackMessageID && stringValue(message["role"]) == "assistant" {
message["thumbup"] = history.Feedback["thumb_up"]
if feedback, ok := history.Feedback["feedback"]; ok {
if feedback == nil {
delete(message, "feedback")
} else {
message["feedback"] = feedback
}
}
}
}
} else if history.Message != nil {
messages = append(messages, history.Message)
if history.AppendReference {
references = append(references, history.Reference)
}
}
updates["message"], _ = json.Marshal(messages)
updates["reference"], _ = json.Marshal(references)
delete(updates, "history_update")
}
f.updateCalled = append(f.updateCalled, struct {
id string
updates map[string]interface{}
@@ -118,7 +163,7 @@ func (f *fakeSessionStore) DeleteByID(ctx context.Context, db *gorm.DB, id strin
return nil
}
func (f *fakeSessionStore) ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name, orderby string, desc bool, page, pageSize int) ([]*entity.ChatSession, error) {
func (f *fakeSessionStore) ListByChatID(ctx context.Context, db *gorm.DB, chatID, sessionID, name, orderby string, desc bool, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, error) {
var result []*entity.ChatSession
for _, s := range f.sessions {
if s.DialogID != chatID {
@@ -174,10 +219,12 @@ type fakePipeline struct {
resultChan <-chan AsyncChatResult
err error
userID string
messages []map[string]interface{}
}
func (f *fakePipeline) AsyncChat(ctx context.Context, userID string, chat *entity.Chat, messages []map[string]interface{}, stream bool, kwargs map[string]interface{}) (<-chan AsyncChatResult, error) {
f.userID = userID
f.messages = messages
return f.resultChan, f.err
}
@@ -832,16 +879,17 @@ func TestApplyChunkFeedback_FallbackClampsAndRemovesPagerank(t *testing.T) {
// Completion tests
// ===================================================================
func TestCompletion_Success(t *testing.T) {
func TestChatCompletions_AppendOnly(t *testing.T) {
store := newFakeSessionStore()
session := &entity.ChatSession{
ID: "session-1", DialogID: "dialog-1",
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
Reference: json.RawMessage(`[]`),
}
store.dialogExists["user-1|dialog-1"] = true
store.sessions["session-1"] = session
store.dialogs["dialog-1"] = &entity.Chat{
ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory",
ID: "dialog-1", TenantID: "user-1", LLMID: "chat@factory",
LLMSetting: entity.JSONMap{},
}
@@ -861,9 +909,10 @@ func TestCompletion_Success(t *testing.T) {
}
ctx := t.Context()
result, err := svc.Completion(ctx, "user-1", "session-1", []map[string]interface{}{
{"role": "user", "content": "hi"},
}, "", nil, "msg-1")
result, err := svc.ChatCompletions(ctx, "user-1", "dialog-1", "session-1", []map[string]interface{}{
{"role": "user", "content": "ignored client history"},
{"id": "msg-1", "role": "user", "content": "hi"},
}, "", nil, "", nil, nil, false, false, nil)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
@@ -888,6 +937,73 @@ func TestCompletion_Success(t *testing.T) {
if got[2]["role"] != "assistant" || got[2]["content"] != "Hello world" || got[2]["id"] != "msg-1" {
t.Fatalf("stored assistant message=%#v", got[2])
}
if len(store.updateCalled) != 2 {
t.Fatalf("want separate question and answer writes, got %d", len(store.updateCalled))
}
pending := parseMessages(store.updateCalled[0].updates["message"].([]byte))
if len(pending) != 2 || pending[1]["role"] != "user" || pending[1]["created_at"] != got[1]["created_at"] {
t.Fatalf("first write must contain only the prologue and timestamped question: %#v", pending)
}
if got[2]["created_at"].(float64) < got[1]["created_at"].(float64) {
t.Fatalf("answer timestamp precedes question: %#v", got)
}
t.Run("non-storing model tests", func(t *testing.T) {
stored := store.sessions["session-1"]
storedMessages, storedReference := string(stored.Message), string(stored.Reference)
payload := []map[string]interface{}{
{"role": "system", "content": "client system prompt"},
{"role": "user", "content": "earlier question"},
{"role": "assistant", "content": "earlier answer"},
{"id": "test-question", "role": "user", "content": "latest question"},
}
var wg sync.WaitGroup
for _, sessionID := range []string{"", "session-1"} {
for _, stream := range []bool{false, true} {
for _, legacy := range []bool{false, true} {
wg.Add(1)
go func() {
defer wg.Done()
ref := map[string]interface{}{"chunks": []interface{}{"test chunk"}}
pipeline := &fakePipeline{resultChan: makeResultChan(
AsyncChatResult{Answer: "test answer", Reference: ref},
AsyncChatResult{Answer: "test answer", Reference: ref, Final: true},
)}
svc := &ChatSessionService{chatSessionDAO: store, userTenantDAO: &fakeTenantStore{}, pipeline: pipeline}
streamChan := make(chan string, 8)
result, err := svc.ChatCompletions(t.Context(), "user-1", "dialog-1", sessionID, payload, "must not replace payload", nil, "", nil, map[string]interface{}{"store_history_messages": false}, legacy, stream, streamChan)
if err != nil {
t.Errorf("completion failed: %v", err)
return
}
if !reflect.DeepEqual(pipeline.messages, payload) {
t.Errorf("full payload not passed: %#v", pipeline.messages)
}
if !stream && (result["answer"] != "test answer" || !reflect.DeepEqual(result["reference"], ref)) {
t.Errorf("unexpected result: %#v", result)
}
if stream {
foundReference := false
for len(streamChan) > 0 {
foundReference = strings.Contains(<-streamChan, "test chunk") || foundReference
}
if !foundReference {
t.Error("stream lost reference")
}
}
}()
}
}
}
wg.Wait()
stored = store.sessions["session-1"]
if len(store.updateCalled) != 2 || len(store.createCalled) != 0 || string(stored.Message) != storedMessages || string(stored.Reference) != storedReference {
t.Fatal("model tests changed stored history or created a session")
}
if _, err := svc.ChatCompletions(t.Context(), "user-1", "dialog-1", "", nil, "question without messages", nil, "", nil, map[string]interface{}{"store_history_messages": false}, false, false, nil); err == nil {
t.Fatal("non-storing completion must require messages")
}
})
}
func TestChatCompletionsPassesRequestUserIDToPipeline(t *testing.T) {
@@ -934,8 +1050,6 @@ func TestChatCompletionsPassesRequestUserIDToPipeline(t *testing.T) {
nil,
nil,
false,
true,
false,
false,
nil,
)
@@ -1017,8 +1131,6 @@ func TestChatCompletionsStreamFinalCarriesDecoratedReference(t *testing.T) {
nil,
false,
true,
false,
true,
streamChan,
)
if err != nil {
@@ -1114,8 +1226,6 @@ func TestChatCompletionsModelIDOverrideUsesModelResolver(t *testing.T) {
nil,
nil,
false,
true,
false,
false,
nil,
)
@@ -1130,252 +1240,6 @@ func TestChatCompletionsModelIDOverrideUsesModelResolver(t *testing.T) {
}
}
// Multi-model comparison sends store_history_messages=false; the ephemeral
// session must never be persisted, otherwise every model card adds a
// "New session" row to the session list.
func TestChatCompletionsStoreHistoryFalseDoesNotPersistSession(t *testing.T) {
store := newFakeSessionStore()
store.dialogs["dialog-1"] = &entity.Chat{
ID: "dialog-1",
TenantID: "tenant-owner",
LLMID: "chat@factory",
LLMSetting: entity.JSONMap{},
PromptConfig: entity.JSONMap{
"parameters": []interface{}{},
"prologue": "Welcome!",
},
}
store.dialogExists["tenant-owner|dialog-1"] = true
pipeline := &fakePipeline{
resultChan: makeResultChan(
AsyncChatResult{Answer: "ok", Final: true, Reference: map[string]interface{}{"chunks": []interface{}{}}},
),
}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{tenantIDs: []string{"tenant-owner"}},
pipeline: pipeline,
}
result, err := svc.ChatCompletions(
t.Context(),
"user-1",
"dialog-1",
"",
[]map[string]interface{}{{"role": "user", "content": "hi"}},
"",
nil,
"",
nil,
nil,
true,
false,
false,
false,
nil,
)
if err != nil {
t.Fatalf("ChatCompletions failed: %v", err)
}
if result == nil {
t.Fatal("expected a non-nil answer")
}
if len(store.createCalled) != 0 {
t.Fatalf("session must not be created when store_history_messages=false, got %d creates", len(store.createCalled))
}
if len(store.updateCalled) != 0 {
t.Fatalf("session must not be updated when store_history_messages=false, got %d updates", len(store.updateCalled))
}
if len(store.sessions) != 0 {
t.Fatalf("no session should be stored, got %d", len(store.sessions))
}
}
func TestCompletion_EmptyMessages(t *testing.T) {
svc := &ChatSessionService{
chatSessionDAO: &fakeSessionStore{},
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
ctx := t.Context()
_, err := svc.Completion(ctx, "user-1", "session-1", nil, "", nil, "msg-1")
if err == nil || err.Error() != "messages cannot be empty" {
t.Fatalf("expected 'messages cannot be empty', got %v", err)
}
}
func TestCompletion_LastMessageNotFromUser(t *testing.T) {
svc := &ChatSessionService{
chatSessionDAO: &fakeSessionStore{},
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
ctx := t.Context()
_, err := svc.Completion(ctx, "user-1", "session-1", []map[string]interface{}{
{"role": "assistant", "content": "hello"},
}, "", nil, "msg-1")
if err == nil || !strings.Contains(err.Error(), "not from user") {
t.Fatalf("expected 'not from user' error, got %v", err)
}
}
func TestCompletion_ConversationNotFound(t *testing.T) {
store := newFakeSessionStore()
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
ctx := t.Context()
_, err := svc.Completion(ctx, "user-1", "missing", []map[string]interface{}{
{"role": "user", "content": "hi"},
}, "", nil, "msg-1")
if err == nil || err.Error() != "conversation not found" {
t.Fatalf("expected 'conversation not found', got %v", err)
}
}
func TestCompletion_DialogNotFound(t *testing.T) {
store := newFakeSessionStore()
store.sessions["session-1"] = &entity.ChatSession{
ID: "session-1", DialogID: "dialog-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{},
}
ctx := t.Context()
_, err := svc.Completion(ctx, "user-1", "session-1", []map[string]interface{}{
{"role": "user", "content": "hi"},
}, "", nil, "msg-1")
if err == nil || err.Error() != "dialog not found" {
t.Fatalf("expected 'dialog not found', got %v", err)
}
}
func TestCompletion_PipelineError(t *testing.T) {
store := newFakeSessionStore()
store.sessions["session-1"] = &entity.ChatSession{
ID: "session-1", DialogID: "dialog-1",
Message: json.RawMessage(`[]`),
Reference: json.RawMessage(`[]`),
}
store.dialogs["dialog-1"] = &entity.Chat{
ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory",
LLMSetting: entity.JSONMap{},
}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: &fakePipeline{err: errors.New("model unavailable")},
}
ctx := t.Context()
_, err := svc.Completion(ctx, "user-1", "session-1", []map[string]interface{}{
{"role": "user", "content": "hi"},
}, "", nil, "msg-1")
if err == nil || err.Error() != "model unavailable" {
t.Fatalf("expected 'model unavailable' error, got %v", err)
}
}
// ===================================================================
// CompletionStream tests
// ===================================================================
func readStreamChan(ch <-chan string, n int) []string {
var msgs []string
for i := 0; i < n; i++ {
select {
case msg, ok := <-ch:
if !ok {
return msgs
}
msgs = append(msgs, msg)
default:
return msgs
}
}
return msgs
}
func TestCompletionStream_Success(t *testing.T) {
store := newFakeSessionStore()
store.sessions["session-1"] = &entity.ChatSession{
ID: "session-1", DialogID: "dialog-1",
Message: json.RawMessage(`{"messages":[{"role":"assistant","content":"Welcome!"}]}`),
Reference: json.RawMessage(`[]`),
}
store.dialogs["dialog-1"] = &entity.Chat{
ID: "dialog-1", TenantID: "tenant-1", LLMID: "chat@factory",
LLMSetting: entity.JSONMap{},
}
pipeline := &fakePipeline{
resultChan: makeResultChan(
AsyncChatResult{Answer: "stream", Reference: map[string]interface{}{"chunks": []interface{}{}}},
AsyncChatResult{Answer: " answer", Reference: map[string]interface{}{"chunks": []interface{}{}}},
),
}
svc := &ChatSessionService{
chatSessionDAO: store,
userTenantDAO: &fakeTenantStore{},
pipeline: pipeline,
}
streamChan := make(chan string, 10)
err := svc.CompletionStream(t.Context(), "user-1", "session-1", []map[string]interface{}{
{"role": "user", "content": "hi"},
}, "", nil, "msg-1", streamChan)
if err != nil {
t.Fatalf("unexpected error: %v", err)
}
// Should receive data events and final signal
msgs := readStreamChan(streamChan, 5)
if len(msgs) < 3 {
t.Fatalf("expected at least 3 stream messages, got %d: %v", len(msgs), msgs)
}
// Check final signal
finalFound := false
for _, m := range msgs {
if strings.Contains(m, `"data":true`) {
finalFound = true
break
}
}
if !finalFound {
t.Fatal("expected final=true signal in stream")
}
got := parseMessages(store.sessions["session-1"].Message)
if len(got) != 3 {
t.Fatalf("stored messages=%#v", got)
}
if got[0]["role"] != "assistant" || got[0]["content"] != "Welcome!" {
t.Fatalf("stored prologue=%#v", got[0])
}
if got[1]["role"] != "user" || got[1]["content"] != "hi" {
t.Fatalf("stored user message=%#v", got[1])
}
if got[2]["role"] != "assistant" || got[2]["content"] != "stream answer" || got[2]["id"] != "msg-1" {
t.Fatalf("stored assistant message=%#v", got[2])
}
}
func TestStructureAnswerWithConv_ParsesArrayMessages(t *testing.T) {
session := &entity.ChatSession{
ID: "session-1",
@@ -1858,7 +1722,7 @@ func TestChatCompletions_SharedSessionReadonlyForTeammate(t *testing.T) {
"session-1",
[]map[string]interface{}{{"role": "user", "content": "hi"}},
"", nil, "", nil, nil,
false, true, false, false, nil,
false, false, nil,
)
if err == nil {
t.Fatalf("expected readonly rejection")
+36
View File
@@ -0,0 +1,36 @@
// Copyright 2026 The InfiniFlow Authors. All Rights Reserved.
// SPDX-License-Identifier: Apache-2.0
package service
import "errors"
// ResolveStoreHistoryMessages defaults to storage; only an explicit false is supported.
func ResolveStoreHistoryMessages(kwargs map[string]interface{}) (bool, error) {
value, exists := kwargs["store_history_messages"]
if !exists {
return true, nil
}
if store, ok := value.(bool); !ok || store {
return false, errors.New("`store_history_messages` only supports false.")
}
return false, nil
}
// ResolveCompletionQuestion picks explicit question/query text before the final user message.
func ResolveCompletionQuestion(question, query string, messages []map[string]interface{}) (string, error) {
if question != "" {
return question, nil
}
if query != "" {
return query, nil
}
if len(messages) == 0 {
return "", nil
}
message := messages[len(messages)-1]
if message["role"] != "user" {
return "", errors.New("The last content of this conversation is not from user.")
}
return NormalizeOpenAIMessageContent(message["content"])
}
@@ -8,7 +8,6 @@ import (
"ragflow/internal/common"
"ragflow/internal/dao"
"ragflow/internal/entity"
"ragflow/internal/parser/parser"
"ragflow/internal/storage"
"ragflow/internal/utility"
@@ -115,12 +114,13 @@ func (s *DocumentService) sandboxArtifactDialogIDsForUser(ctx context.Context, f
filenamePattern := "%" + filenameSafe + "%"
artifactRefPattern := "%" + artifactRefSafe + "%"
dialogIDs := make(map[string]struct{})
rows, err := dao.DB.WithContext(ctx).Model(&entity.API4Conversation{}).
Select("dialog_id").
Where("user_id = ? OR exp_user_id = ?", userID, userID).
Where(`message LIKE ? ESCAPE '!' OR message LIKE ? ESCAPE '!'`,
rows, err := dao.DB.WithContext(ctx).Table("api_4_conversation AS c").
Select("c.dialog_id").
Joins("JOIN api_4_conversation_message AS cm ON cm.conversation_id = c.id").
Where("c.user_id = ? OR c.exp_user_id = ?", userID, userID).
Where(`cm.content LIKE ? ESCAPE '!' OR cm.content LIKE ? ESCAPE '!'`,
filenamePattern, artifactRefPattern).
Distinct("dialog_id").
Distinct("c.dialog_id").
Rows()
if err != nil {
return nil
+21 -4
View File
@@ -3185,6 +3185,8 @@ func TestGetDocumentArtifact_AuthGate(t *testing.T) {
if err := db.AutoMigrate(
&entity.UserCanvas{},
&entity.API4Conversation{},
&entity.API4ConversationMessage{},
&entity.API4ConversationReference{},
); err != nil {
t.Fatalf("migrate: %v", err)
}
@@ -3202,18 +3204,33 @@ func TestGetDocumentArtifact_AuthGate(t *testing.T) {
t.Fatalf("seed canvas: %v", err)
}
// Seed an API4Conversation whose message references the filename.
if err := db.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), db, &entity.API4Conversation{
ID: "sess-1",
DialogID: "agent-1",
UserID: "user-1",
Message: json.RawMessage(`[{"role":"assistant","content":"saved as documents/artifact/result.png"}]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("seed conv: %v", err)
}
svc := testDocumentService(t)
ctx := t.Context()
mockStorage := useFakeStorage(t)
data := []byte("artifact content")
if err := mockStorage.Put(ctx, sandboxArtifactBucket(), "result.png", data); err != nil {
t.Fatalf("seed artifact: %v", err)
}
// Case 0: the owner can fetch the artifact referenced by their session.
artifact, err := svc.GetDocumentArtifact(ctx, "result.png", "user-1")
if err != nil {
t.Fatalf("owner: want artifact, got %v", err)
}
if !bytes.Equal(artifact.Data, data) || artifact.ContentType != "image/png" || artifact.SafeFilename != "result.png" {
t.Errorf("owner: unexpected artifact: %+v", artifact)
}
// Case 1: empty user -> not allowed.
if _, err := svc.GetDocumentArtifact(ctx, "result.png", ""); !errors.Is(err, ErrArtifactNotFound) {
t.Errorf("empty user: want ErrArtifactNotFound, got %v", err)
@@ -3234,12 +3251,12 @@ func TestGetDocumentArtifact_AuthGate(t *testing.T) {
}).Error; err != nil {
t.Fatalf("seed canvas 2: %v", err)
}
if err := db.Create(&entity.API4Conversation{
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), db, &entity.API4Conversation{
ID: "sess-2",
DialogID: "agent-2",
UserID: "user-2",
Message: json.RawMessage(`[{"role":"user","content":"hello"}]`),
}).Error; err != nil {
}); err != nil {
t.Fatalf("seed conv 2: %v", err)
}
if _, err := svc.GetDocumentArtifact(ctx, "result.png", "user-2"); !errors.Is(err, ErrArtifactNotFound) {
+13
View File
@@ -124,6 +124,8 @@ func NewOpenAIChatService() *OpenAIChatService {
// OpenAIChatRequest mirrors the OpenAI Chat Completions request body.
// `stop` and `user` are omitted intentionally — JSON unmarshal silently drops them.
type OpenAIChatRequest struct {
Question string `json:"question,omitempty"`
Query string `json:"query,omitempty"`
Model string `json:"model"`
Messages []map[string]interface{} `json:"messages"`
Stream *bool `json:"stream,omitempty"`
@@ -142,6 +144,17 @@ func (s *OpenAIChatService) OpenAIChatCompletions(c *gin.Context, userID, chatID
s.writeArgError(c, err.Error())
return
}
question, err := ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
s.writeDataError(c, err.Error())
return
}
if req.Question != "" || req.Query != "" {
req.Messages = []map[string]interface{}{{"role": "user", "content": question}}
}
if len(req.Messages) > 0 {
req.Messages = req.Messages[len(req.Messages)-1:]
}
common.Info("OpenAIChatCompletions started", zap.String("chat_id", chatID))
normalizedMessages, err := normalizeOpenAIMessages(req.Messages)
+9 -3
View File
@@ -365,7 +365,11 @@ func (s *SearchService) PrepareCompletion(ctx context.Context, userID, searchID
if req == nil {
return nil, common.CodeArgumentError, fmt.Errorf("question is required")
}
question := strings.TrimSpace(req.Question)
question, err := ResolveCompletionQuestion(req.Question, req.Query, req.Messages)
if err != nil {
return nil, common.CodeArgumentError, err
}
question = strings.TrimSpace(question)
if question == "" {
return nil, common.CodeArgumentError, fmt.Errorf("question is required")
}
@@ -683,6 +687,8 @@ func (s *SearchService) GetDetail(ctx context.Context, searchID string) (map[str
}
type SearchCompletionsRequest struct {
Question string `json:"question" binding:"required"`
KBIDs []string `json:"kb_ids,omitempty"`
Query string `json:"query,omitempty"`
Messages []map[string]interface{} `json:"messages,omitempty"`
Question string `json:"question"`
KBIDs []string `json:"kb_ids,omitempty"`
}
+17
View File
@@ -57,6 +57,11 @@ func setupServiceTestDB(t *testing.T) *gorm.DB {
&entity.Tenant{},
&entity.UserTenant{},
&entity.API4Conversation{},
&entity.API4ConversationMessage{},
&entity.API4ConversationReference{},
&entity.ChatSession{},
&entity.ConversationMessage{},
&entity.ConversationReference{},
); err != nil {
t.Fatalf("failed to migrate: %v", err)
}
@@ -74,6 +79,18 @@ func pushServiceDB(t *testing.T, testDB *gorm.DB) {
})
}
func getAPIConversationForTest(t *testing.T, db *gorm.DB, id string) *entity.API4Conversation {
t.Helper()
conversation, err := dao.NewAPI4ConversationDAO().GetByID(t.Context(), db, id)
if err != nil {
t.Fatalf("failed to load API conversation %s: %v", id, err)
}
if conversation == nil {
t.Fatalf("API conversation %s not found", id)
}
return conversation
}
// fakeChatDocEngine is a stub engine.DocEngine used by parent-package tests.
type fakeChatDocEngine struct{}