mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-10-12 02:16:39 +00:00
Refactor: move chat message/reference to dedicated tables (#19577)
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -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))
|
||||
}
|
||||
|
||||
@@ -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
@@ -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:
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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"`
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.")
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
@@ -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])
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@@ -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"])
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -321,8 +321,6 @@ func (s *ChatChannelService) HandleIncomingMessage(ctx context.Context, msg Chat
|
||||
nil,
|
||||
map[string]interface{}{"quote": false},
|
||||
false,
|
||||
true,
|
||||
false,
|
||||
false,
|
||||
nil,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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")
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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{}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user