mirror of
https://github.com/infiniflow/ragflow.git
synced 2026-11-10 21:32:43 +08:00
Refactor: move reference into message table (#21705)
This commit is contained in:
1 parent
84e09c0c00
commit
ffef28abe9
50 files changed
+1464
-871
No files matched your search
@@ -16,7 +16,7 @@ A record of the schema and data changes the Go backend applies to a RAGFlow data
|
||||
|
||||
- The marker is a row in `system_settings` with the key `mysql_migration.database.version`.
|
||||
- For version-gated steps, the marker prevents a migration step from running again after that step has completed successfully.
|
||||
- Go defines the versions as constants: `modelMigrationBaseVersion` and `modelMigrationTargetVersion` in [migration_version.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/migration_version.go), `conversationHistoryTargetVersion` in [conversation_history_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/conversation_history_migration.go), `knowledgebaseParserConfigTargetVersion` in [migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/migration.go), and `ingestionParserConfigTargetVersion` in [ingestion_parser_config_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/ingestion_parser_config_migration.go). The two parser-config constants share `v1.0.0-rc2`. The DSL multi-version backfill is gated by `dslVersionTargetVersion` (`v1.0.0-rc4`) in [dsl_version_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/dsl_version_migration.go).
|
||||
- Go defines the versions as constants: `modelMigrationBaseVersion` and `modelMigrationTargetVersion` in [migration_version.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/migration_version.go), `conversationHistoryTargetVersion` in [conversation_history_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/conversation_history_migration.go), `knowledgebaseParserConfigTargetVersion` in [migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/migration.go), and `ingestionParserConfigTargetVersion` in [ingestion_parser_config_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/ingestion_parser_config_migration.go). The two parser-config constants share `v1.0.0-rc2`. The DSL multi-version backfill is gated by `dslVersionTargetVersion` (`v1.0.0-rc3.dev6`) in [dsl_version_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/dsl_version_migration.go).
|
||||
- The current permission migration targets `permissionMigrationTargetVersion` (`v1.0.0-rc3.dev4`) in [permission_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/permission_migration.go). Tenant default-model migration targets `tenantDefaultModelsTargetVersion` (`v1.0.0-rc3.dev5`) in [tenant_default_models_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/tenant_default_models_migration.go).
|
||||
- The compilation queue split records `knowledgeCompileQueueTargetVersion` (`v1.0.0-rc3.dev5`), defined in [knowledge_compile_queue_migration.go](https://github.com/infiniflow/ragflow/blob/main/internal/dao/knowledge_compile_queue_migration.go), after both queues finish. Source-column presence controls the queue copy even when the marker already contains this or a newer version.
|
||||
- A version-gated step runs when the stored version is lower than the step's version. Versions are compared with Python PEP 440 semantics, where a `dev` prerelease sorts *before* the release it precedes (`v1.0.0-rc1.dev1` < `v1.0.0-rc1`). A missing or unparsable marker never skips a step.
|
||||
@@ -35,6 +35,8 @@ Startup order in `InitDB` for `ragflow_server --migrate` ([database.go](https://
|
||||
9. `migrateTenantDefaultModels` — moves tenant-wide model selections to owner memberships, drops the tenant columns, and records `v1.0.0-rc3.dev5`.
|
||||
10. `migrateTenantModelProviderUserUniqueIndex` — reconciles provider ownership indexes.
|
||||
11. `migrateKnowledgeCompileQueues` — copies the existing compilation queues and drops their source columns, then records `v1.0.0-rc3.dev5` if the marker is older. It runs after tenant default-model migration so the queue split cannot make that step skip its data changes. Queue copying remains gated on source-column presence even when the preceding step has already recorded `dev5`.
|
||||
12. `MigrateDSLVersionColumns` and `BackfillDSLVersionBindings` — add pipeline version columns and bind existing dataflow canvases, then record `v1.0.0-rc3.dev6`.
|
||||
13. `migrateConversationReferences` — move answer references into message rows and record `v1.0.0-rc3.dev7`.
|
||||
|
||||
## Version overview
|
||||
|
||||
@@ -50,7 +52,8 @@ Startup order in `InitDB` for `ragflow_server --migrate` ([database.go](https://
|
||||
| `v1.0.0-rc3.dev4` | Normalize legacy tenant invitation roles and statuses | `permissionMigrationTargetVersion` |
|
||||
| `v1.0.0-rc3.dev5` | Move tenant default-model selections to owner memberships and drop the tenant columns | `tenantDefaultModelsTargetVersion` |
|
||||
| `v1.0.0-rc3.dev5` | Split compilation queues into `knowledge_compile_docs_backlog` and `knowledge_compile_docs_inflight`, then drop their source columns | `knowledgeCompileQueueTargetVersion`; source-column presence controls copying |
|
||||
| `v1.0.0-rc4` | Lock existing dataflow-canvas dataset/document bindings to the canvas latest version (the `pipeline_version_id` columns are added by the version-less step below) | `dslVersionTargetVersion` |
|
||||
| `v1.0.0-rc3.dev6` | Lock existing dataflow-canvas dataset/document bindings to the canvas latest version (the `pipeline_version_id` columns are added by the version-less step below) | `dslVersionTargetVersion` |
|
||||
| `v1.0.0-rc3.dev7` | Store conversation references in each assistant message | `conversationReferenceTargetVersion` |
|
||||
|
||||
The two tenant model steps are cumulative: a database at `v0.26.0` still runs the `v1.0.0-rc1` step, and a database at or above `v1.0.0-rc1` runs neither.
|
||||
|
||||
@@ -257,7 +260,7 @@ This migration requires a coordinated service stop:
|
||||
|
||||
Starting a server while either source column remains fails with `knowledge compilation queues need migration`. A database version marker at or above the current release does not bypass this requirement. If migration fails, keep services stopped, resolve the reported error, and rerun `ragflow_server --migrate`.
|
||||
|
||||
## v1.0.0-rc4
|
||||
## v1.0.0-rc3.dev6
|
||||
|
||||
Locks every pre-existing dataflow-canvas binding to the latest version of that canvas, so already-configured datasets and documents keep parsing against the version that was current when the upgrade ran (design section 5/B4 sticky snapshot). The `pipeline_version_id` columns it writes are added by the version-less step below; this step only backfills their values and is gated by the `dslVersionTargetVersion` marker.
|
||||
|
||||
@@ -270,9 +273,50 @@ Locks every pre-existing dataflow-canvas binding to the latest version of that c
|
||||
Execution details:
|
||||
|
||||
- Rows are read in id-ordered batches of 256, each batch in its own transaction, so an interrupted run resumes cleanly.
|
||||
- The marker advances to `v1.0.0-rc4` only after both tables are processed. Databases already marked `v1.0.0-rc4` or later skip the backfill.
|
||||
- The marker advances to `v1.0.0-rc3.dev6` only after both tables are processed. Databases already marked `v1.0.0-rc3.dev6` or later skip the backfill.
|
||||
|
||||
On success the step writes `v1.0.0-rc4` into the version marker.
|
||||
On success the step writes `v1.0.0-rc3.dev6` into the version marker.
|
||||
|
||||
---
|
||||
|
||||
## v1.0.0-rc3.dev7
|
||||
|
||||
Adds a nullable `reference` LONGTEXT column to `conversation_message` and
|
||||
`api_4_conversation_message`. New chat, agent, and channel answers store their
|
||||
reference object directly on their message row.
|
||||
|
||||
Run `ragflow_server --migrate` with writers stopped before starting the new
|
||||
services. The migration copies `conversation_reference` and
|
||||
`api_4_conversation_reference` into the corresponding assistant messages. It
|
||||
uses `message_position` when present; older array entries follow assistant
|
||||
order after the first user message, excluding the prologue. Rows are read in
|
||||
batches of 100 and written in transactions. Reruns preserve reference objects
|
||||
already stored on messages. References duplicated in message metadata are moved
|
||||
into the column and removed from metadata, so generation reads avoid that data.
|
||||
|
||||
After both message tables are copied successfully, the migration drops
|
||||
`conversation_reference` and `api_4_conversation_reference`. Automatic schema
|
||||
creation no longer recreates these tables. Source-table presence triggers copying
|
||||
and cleanup even if the marker already contains `v1.0.0-rc3.dev7` or a newer version;
|
||||
a newer marker is preserved.
|
||||
|
||||
References without a surviving assistant message are skipped and counted in a
|
||||
warning. These include references left behind by deleted messages, empty histories,
|
||||
and unsuccessful turns. They are removed with the source tables and are never
|
||||
attached to another answer. Invalid JSON or a copy error stops the migration before
|
||||
either source table is dropped. Resolve the reported issue and rerun it. Completed
|
||||
batches are safe to retry. The marker advances to `v1.0.0-rc3.dev7` only after both
|
||||
copies and table drops finish. A failed table drop can be retried even if the
|
||||
other source table has already been dropped.
|
||||
|
||||
Session responses return `reference` within each answer, with no session-level
|
||||
reference array when `include_reference=true`. Clients must read
|
||||
`messages[i].reference` (or `message[i].reference` for agent sessions).
|
||||
`include_reference` and `include_prompt` both default to `false`; omitted fields
|
||||
are excluded from SQL as well as the response. The UI requests the fields it
|
||||
displays. Generation history SQL omits
|
||||
`reference`, `prompt`, `usage`, and `elapsed_ms`; session detail reads request
|
||||
the fields they display.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -3203,6 +3203,11 @@ curl --request GET \
|
||||
|
||||
When `id` or `name` is provided, exact filtering takes precedence over `keywords`.
|
||||
|
||||
- `include_reference`: (*Query parameter*) `boolean`, defaults to `false`.
|
||||
Include reference objects in answer messages when history is included.
|
||||
- `include_prompt`: (*Query parameter*) `boolean`, defaults to `false`.
|
||||
Include recorded prompt strings in answer messages when history is included.
|
||||
|
||||
#### Response
|
||||
|
||||
Success:
|
||||
@@ -3490,7 +3495,6 @@ Success:
|
||||
}
|
||||
],
|
||||
"name": "new session",
|
||||
"reference": [],
|
||||
"update_date": "Fri, 11 Oct 2024 08:46:43 GMT",
|
||||
"update_time": 1728636403974,
|
||||
"user_id": ""
|
||||
@@ -3516,13 +3520,26 @@ Failure:
|
||||
|
||||
Gets a specific session of a specified chat assistant, including its messages, references, and avatar.
|
||||
|
||||
Completed assistant entries in `messages` include the recorded `prompt` (string),
|
||||
`usage` (object with `prompt_tokens`, `completion_tokens`, and `total_tokens`), and
|
||||
`elapsed_ms` (number, in milliseconds). These fields are also returned by the
|
||||
session list when history is included. Older messages and turns without recorded
|
||||
usage or timing omit the unavailable fields. They are used for displaying answer
|
||||
details and are excluded from history passed to the LLM for subsequent turns.
|
||||
Agentic answers store and return `usage` and `elapsed_ms` without a `prompt`.
|
||||
Completed assistant entries in `messages` include recorded `usage` (an object
|
||||
with `prompt_tokens`, `completion_tokens`, and `total_tokens`) and `elapsed_ms`
|
||||
(a number in milliseconds). Large answer fields are opt-in:
|
||||
|
||||
- `include_reference`: boolean, defaults to `false`. Set to `true` to return a
|
||||
`reference` object containing `chunks` and `doc_aggs` inside each answer.
|
||||
- `include_prompt`: boolean, defaults to `false`. Set to `true` to return the
|
||||
recorded prompt string inside each answer.
|
||||
|
||||
These query parameters apply only to chat and agent session list and detail
|
||||
endpoints. Fields omitted by these flags are excluded from SQL as well as the
|
||||
response. Invalid boolean values return an argument error on these read endpoints.
|
||||
Create, update, message deletion, and feedback endpoints ignore these query
|
||||
parameters and omit `reference` and `prompt` from their responses.
|
||||
There is no session-level reference array.
|
||||
|
||||
The UI requests references for citations and source previews, and prompts for
|
||||
answer details. Older messages omit unavailable usage, timing, or prompt fields.
|
||||
Generation history SQL omits `reference`, `prompt`, `usage`, and `elapsed_ms`, and
|
||||
these fields are excluded from LLM input. Agentic answers have no recorded prompt.
|
||||
|
||||
#### Request
|
||||
|
||||
@@ -3535,7 +3552,7 @@ Agentic answers store and return `usage` and `elapsed_ms` without a `prompt`.
|
||||
|
||||
```bash
|
||||
curl --request GET \
|
||||
--url http://{address}/api/v1/chats/{chat_id}/sessions/{session_id} \
|
||||
--url 'http://{address}/api/v1/chats/{chat_id}/sessions/{session_id}?include_reference=true&include_prompt=true' \
|
||||
--header 'Authorization: Bearer <YOUR_API_KEY>'
|
||||
```
|
||||
|
||||
@@ -3562,9 +3579,19 @@ Success:
|
||||
{
|
||||
"content": "Hi! I am your assistant, can I help you?",
|
||||
"role": "assistant"
|
||||
},
|
||||
{
|
||||
"id": "turn-1",
|
||||
"role": "user",
|
||||
"content": "What does the document say?"
|
||||
},
|
||||
{
|
||||
"id": "turn-1",
|
||||
"role": "assistant",
|
||||
"content": "Here is the answer.",
|
||||
"reference": {"chunks": [], "doc_aggs": []}
|
||||
}
|
||||
],
|
||||
"reference": []
|
||||
]
|
||||
}
|
||||
}
|
||||
```
|
||||
@@ -3620,8 +3647,7 @@ Success: returns the updated session object.
|
||||
"data": {
|
||||
"chat_id": "2ca4b22e878011ef88fe0242ac120005",
|
||||
"id": "4606b4ec87ad11efbc4f0242ac120006",
|
||||
"messages": [],
|
||||
"reference": []
|
||||
"messages": []
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
@@ -56,10 +56,7 @@ func (dao *API4ConversationDAO) Create(ctx context.Context, db *gorm.DB, conv *e
|
||||
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)
|
||||
return createHistory(ctx, tx, apiConversationMessageTable, "message", conv.ID, conv.Message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -85,7 +82,7 @@ func (dao *API4ConversationDAO) UpdateHistory(ctx context.Context, db *gorm.DB,
|
||||
if err := query.Updates(updates).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
return updateConversationHistory(ctx, tx, apiConversationMessageTable, apiConversationReferenceTable, sessionID, history)
|
||||
return updateConversationHistory(ctx, tx, apiConversationMessageTable, sessionID, history)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -118,12 +115,16 @@ func (dao *API4ConversationDAO) Stats(ctx context.Context, db *gorm.DB, tenantID
|
||||
return rows, err
|
||||
}
|
||||
|
||||
func (dao *API4ConversationDAO) GetBySessionID(ctx context.Context, db *gorm.DB, sessionID, agentID string) (*entity.API4Conversation, error) {
|
||||
func (dao *API4ConversationDAO) GetBySessionID(ctx context.Context, db *gorm.DB, sessionID, agentID string, options ...ConversationHistoryOptions) (*entity.API4Conversation, error) {
|
||||
historyOptions := ConversationHistoryOptions{}
|
||||
if len(options) > 0 {
|
||||
historyOptions = options[0]
|
||||
}
|
||||
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 {
|
||||
if err := hydrateAPIConversations(ctx, db, []*entity.API4Conversation{result}, historyOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return result, nil
|
||||
@@ -144,7 +145,11 @@ func (dao *API4ConversationDAO) GetMetadataBySessionID(ctx context.Context, db *
|
||||
|
||||
// GetByID returns a conversation without requiring the caller to know its
|
||||
// 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) {
|
||||
func (dao *API4ConversationDAO) GetByID(ctx context.Context, db *gorm.DB, id string, options ...ConversationHistoryOptions) (*entity.API4Conversation, error) {
|
||||
historyOptions := ConversationHistoryOptions{}
|
||||
if len(options) > 0 {
|
||||
historyOptions = options[0]
|
||||
}
|
||||
var result entity.API4Conversation
|
||||
tx := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Take(&result, "id = ?", id)
|
||||
if tx.Error != nil {
|
||||
@@ -153,7 +158,7 @@ func (dao *API4ConversationDAO) GetByID(ctx context.Context, db *gorm.DB, id str
|
||||
}
|
||||
return nil, tx.Error
|
||||
}
|
||||
if err := hydrateAPIConversations(ctx, db, []*entity.API4Conversation{&result}); err != nil {
|
||||
if err := hydrateAPIConversations(ctx, db, []*entity.API4Conversation{&result}, historyOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &result, nil
|
||||
@@ -174,7 +179,7 @@ func (dao *API4ConversationDAO) DeleteBySessionIDAndAgentID(ctx context.Context,
|
||||
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 {
|
||||
if err := deleteHistory(ctx, tx, []string{apiConversationMessageTable}, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Where("id = ? AND dialog_id = ?", sessionID, agentID).Delete(&entity.API4Conversation{})
|
||||
@@ -195,7 +200,7 @@ func (dao *API4ConversationDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.
|
||||
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 {
|
||||
if err := deleteHistory(ctx, tx, []string{apiConversationMessageTable}, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Where("dialog_id IN ?", dialogIDs).Delete(&entity.API4Conversation{})
|
||||
|
||||
@@ -48,11 +48,10 @@ func createAPI4ConversationForDAOTest(t *testing.T, id, agentID string) {
|
||||
t.Helper()
|
||||
ctx := t.Context()
|
||||
if err := NewAPI4ConversationDAO().Create(ctx, DB, &entity.API4Conversation{
|
||||
ID: id,
|
||||
DialogID: agentID,
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: id,
|
||||
DialogID: agentID,
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
}); err != nil {
|
||||
t.Fatalf("failed to create api conversation %s: %v", id, err)
|
||||
}
|
||||
|
||||
@@ -33,20 +33,21 @@ import (
|
||||
type ChatSessionDAO struct{}
|
||||
|
||||
type ListAgentSessionsParams struct {
|
||||
AgentID string
|
||||
Page int
|
||||
PageSize int
|
||||
OrderBy string
|
||||
Desc bool
|
||||
SessionID string
|
||||
UserID string
|
||||
TenantID string
|
||||
IncludeDSL bool
|
||||
NoHistory bool
|
||||
Keywords string
|
||||
FromDate *time.Time
|
||||
ToDate *time.Time
|
||||
ExpUserID string
|
||||
AgentID string
|
||||
Page int
|
||||
PageSize int
|
||||
OrderBy string
|
||||
Desc bool
|
||||
SessionID string
|
||||
UserID string
|
||||
TenantID string
|
||||
IncludeDSL bool
|
||||
NoHistory bool
|
||||
HistoryOptions ConversationHistoryOptions
|
||||
Keywords string
|
||||
FromDate *time.Time
|
||||
ToDate *time.Time
|
||||
ExpUserID string
|
||||
}
|
||||
|
||||
// NewChatSessionDAO create chat session DAO
|
||||
@@ -55,26 +56,34 @@ func NewChatSessionDAO() *ChatSessionDAO {
|
||||
}
|
||||
|
||||
// GetByID gets chat session by ID
|
||||
func (dao *ChatSessionDAO) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.ChatSession, error) {
|
||||
func (dao *ChatSessionDAO) GetByID(ctx context.Context, db *gorm.DB, id string, options ...ConversationHistoryOptions) (*entity.ChatSession, error) {
|
||||
historyOptions := ConversationHistoryOptions{}
|
||||
if len(options) > 0 {
|
||||
historyOptions = options[0]
|
||||
}
|
||||
var conv entity.ChatSession
|
||||
err := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Take(&conv, "id = ?", id).Error
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err = hydrateChatSessions(ctx, db, []*entity.ChatSession{&conv}); err != nil {
|
||||
if err = hydrateChatSessions(ctx, db, []*entity.ChatSession{&conv}, historyOptions); 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) {
|
||||
func (dao *ChatSessionDAO) GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string, options ...ConversationHistoryOptions) (*entity.ChatSession, error) {
|
||||
historyOptions := ConversationHistoryOptions{}
|
||||
if len(options) > 0 {
|
||||
historyOptions = options[0]
|
||||
}
|
||||
var conv entity.ChatSession
|
||||
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 {
|
||||
if err = hydrateChatSessions(ctx, db, []*entity.ChatSession{&conv}, historyOptions); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return &conv, nil
|
||||
@@ -86,10 +95,7 @@ func (dao *ChatSessionDAO) Create(ctx context.Context, db *gorm.DB, conv *entity
|
||||
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)
|
||||
return createHistory(ctx, tx, conversationMessageTable, "message", conv.ID, conv.Message)
|
||||
})
|
||||
}
|
||||
|
||||
@@ -127,7 +133,7 @@ func (dao *ChatSessionDAO) UpdateByID(ctx context.Context, db *gorm.DB, id strin
|
||||
}
|
||||
}
|
||||
if targeted {
|
||||
return updateConversationHistory(ctx, tx, conversationMessageTable, conversationReferenceTable, id, historyUpdate)
|
||||
return updateConversationHistory(ctx, tx, conversationMessageTable, id, historyUpdate)
|
||||
}
|
||||
return nil
|
||||
})
|
||||
@@ -140,7 +146,7 @@ func (dao *ChatSessionDAO) DeleteByID(ctx context.Context, db *gorm.DB, id strin
|
||||
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 {
|
||||
if err := deleteHistory(ctx, tx, []string{conversationMessageTable}, []string{id}); err != nil {
|
||||
return err
|
||||
}
|
||||
return tx.Where("id = ?", id).Delete(&entity.ChatSession{}).Error
|
||||
@@ -148,7 +154,7 @@ func (dao *ChatSessionDAO) DeleteByID(ctx context.Context, db *gorm.DB, id strin
|
||||
}
|
||||
|
||||
// ListByResourceIDs lists sessions for a chat, optionally restricted to sessionIDs.
|
||||
func (dao *ChatSessionDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, terms []OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, int64, error) {
|
||||
func (dao *ChatSessionDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, terms []OrderTerm, page, pageSize int, options ...ConversationHistoryOptions) ([]*entity.ChatSession, int64, error) {
|
||||
if sessionIDs != nil && len(sessionIDs) == 0 {
|
||||
return []*entity.ChatSession{}, 0, nil
|
||||
}
|
||||
@@ -181,8 +187,12 @@ func (dao *ChatSessionDAO) ListByResourceIDs(ctx context.Context, db *gorm.DB, c
|
||||
if err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
if len(includeHistory) == 0 || includeHistory[0] {
|
||||
if err = hydrateChatSessions(ctx, db, chatSessions); err != nil {
|
||||
historyOptions := ConversationHistoryOptions{}
|
||||
if len(options) > 0 {
|
||||
historyOptions = options[0]
|
||||
}
|
||||
if !historyOptions.NoHistory {
|
||||
if err = hydrateChatSessions(ctx, db, chatSessions, historyOptions); err != nil {
|
||||
return nil, 0, err
|
||||
}
|
||||
}
|
||||
@@ -210,7 +220,7 @@ func (dao *ChatSessionDAO) DeleteByDialogIDs(ctx context.Context, db *gorm.DB, d
|
||||
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 {
|
||||
if err := deleteHistory(ctx, tx, []string{conversationMessageTable}, ids); err != nil {
|
||||
return err
|
||||
}
|
||||
result := tx.Unscoped().Where("dialog_id IN ?", dialogIDs).Delete(&entity.ChatSession{})
|
||||
@@ -341,7 +351,7 @@ func (dao *ChatSessionDAO) ListAgentSessions(ctx context.Context, db *gorm.DB, p
|
||||
return 0, nil, err
|
||||
}
|
||||
if !params.NoHistory {
|
||||
if err = hydrateAPIConversations(ctx, db, sessions); err != nil {
|
||||
if err = hydrateAPIConversations(ctx, db, sessions, params.HistoryOptions); err != nil {
|
||||
return 0, nil, err
|
||||
}
|
||||
}
|
||||
|
||||
@@ -50,11 +50,10 @@ func createAgentSessionForDAOTest(t *testing.T, db *gorm.DB, id, agentID, userID
|
||||
|
||||
updateDate := time.UnixMilli(updateTime).Local()
|
||||
session := &entity.API4Conversation{
|
||||
ID: id,
|
||||
DialogID: agentID,
|
||||
UserID: userID,
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: id,
|
||||
DialogID: agentID,
|
||||
UserID: userID,
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`),
|
||||
BaseModel: entity.BaseModel{
|
||||
CreateTime: &updateTime,
|
||||
CreateDate: &updateDate,
|
||||
@@ -72,12 +71,11 @@ func createNamedAgentSessionForDAOTest(t *testing.T, db *gorm.DB, id, agentID, u
|
||||
|
||||
updateDate := time.UnixMilli(updateTime).Local()
|
||||
session := &entity.API4Conversation{
|
||||
ID: id,
|
||||
Name: &name,
|
||||
DialogID: agentID,
|
||||
UserID: userID,
|
||||
Message: message,
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: id,
|
||||
Name: &name,
|
||||
DialogID: agentID,
|
||||
UserID: userID,
|
||||
Message: message,
|
||||
BaseModel: entity.BaseModel{
|
||||
CreateTime: &updateTime,
|
||||
CreateDate: &updateDate,
|
||||
@@ -95,11 +93,10 @@ func createChatSessionForDAOTest(t *testing.T, db *gorm.DB, id, chatID, name str
|
||||
|
||||
updateDate := time.UnixMilli(updateTime).Local()
|
||||
session := &entity.ChatSession{
|
||||
ID: id,
|
||||
DialogID: chatID,
|
||||
Name: &name,
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: id,
|
||||
DialogID: chatID,
|
||||
Name: &name,
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`),
|
||||
BaseModel: entity.BaseModel{
|
||||
CreateTime: &updateTime,
|
||||
CreateDate: &updateDate,
|
||||
|
||||
@@ -31,19 +31,21 @@ import (
|
||||
)
|
||||
|
||||
const (
|
||||
conversationMessageTable = "conversation_message"
|
||||
conversationReferenceTable = "conversation_reference"
|
||||
apiConversationMessageTable = "api_4_conversation_message"
|
||||
apiConversationReferenceTable = "api_4_conversation_reference"
|
||||
conversationHistoryIDColumn = "conversation_id"
|
||||
conversationHistoryOrderColumn = "position"
|
||||
conversationMessageTable = "conversation_message"
|
||||
conversationReferenceTable = "conversation_reference"
|
||||
apiConversationMessageTable = "api_4_conversation_message"
|
||||
apiConversationReferenceTable = "api_4_conversation_reference"
|
||||
conversationHistoryIDColumn = "conversation_id"
|
||||
)
|
||||
|
||||
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"`
|
||||
// ConversationHistoryOptions selects message payloads needed by the caller.
|
||||
// The zero value loads history without answer details.
|
||||
type ConversationHistoryOptions struct {
|
||||
NoHistory bool
|
||||
IncludeReference bool
|
||||
IncludePrompt bool
|
||||
IncludeUsage bool
|
||||
IncludeElapsedMs bool
|
||||
}
|
||||
|
||||
// ConversationHistoryUpdate changes only the addressed turn, never synchronizing
|
||||
@@ -51,39 +53,12 @@ type conversationHistoryRow struct {
|
||||
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 {
|
||||
func updateConversationHistory(ctx context.Context, db *gorm.DB, messageTable, 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)
|
||||
@@ -98,15 +73,6 @@ func updateConversationHistory(ctx context.Context, db *gorm.DB, messageTable, r
|
||||
}
|
||||
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
|
||||
}
|
||||
@@ -147,7 +113,7 @@ func updateConversationHistory(ctx context.Context, db *gorm.DB, messageTable, r
|
||||
update.Feedback["metadata"] = string(raw)
|
||||
return query.Where("position = ?", message.Position).Updates(update.Feedback).Error
|
||||
}
|
||||
if update.Message == nil && !update.AppendReference {
|
||||
if update.Message == nil {
|
||||
return nil
|
||||
}
|
||||
var fields entity.ConversationMessageFields
|
||||
@@ -169,14 +135,11 @@ func updateConversationHistory(ctx context.Context, db *gorm.DB, messageTable, r
|
||||
}
|
||||
position = question.Position + 1
|
||||
} else {
|
||||
var lastMessage, lastReference int
|
||||
var lastMessage 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
|
||||
position = lastMessage + 2
|
||||
}
|
||||
if update.Message != nil {
|
||||
values := messageValues(fields)
|
||||
@@ -186,17 +149,6 @@ func updateConversationHistory(ctx context.Context, db *gorm.DB, messageTable, r
|
||||
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
|
||||
}
|
||||
|
||||
@@ -266,7 +218,7 @@ func flattenMessage(item json.RawMessage) (entity.ConversationMessageFields, err
|
||||
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,
|
||||
"prompt": &fields.Prompt, "usage": &fields.Usage, "elapsed_ms": &fields.ElapsedMs,
|
||||
"prompt": &fields.Prompt, "usage": &fields.Usage, "elapsed_ms": &fields.ElapsedMs, "reference": &fields.Reference,
|
||||
} {
|
||||
if value, ok := metadata[key]; ok && !bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
if err := json.Unmarshal(value, target); err == nil {
|
||||
@@ -294,7 +246,7 @@ func flattenMessage(item json.RawMessage) (entity.ConversationMessageFields, err
|
||||
return fields, err
|
||||
}
|
||||
|
||||
func marshalMessage(fields entity.ConversationMessageFields) (json.RawMessage, error) {
|
||||
func marshalMessage(fields entity.ConversationMessageFields, options ConversationHistoryOptions) (json.RawMessage, error) {
|
||||
metadata := make(map[string]json.RawMessage)
|
||||
if len(fields.Metadata) > 0 {
|
||||
if err := json.Unmarshal([]byte(fields.Metadata), &metadata); err != nil {
|
||||
@@ -318,14 +270,26 @@ func marshalMessage(fields entity.ConversationMessageFields) (json.RawMessage, e
|
||||
if fields.Content != nil && fields.ContentType == "json" {
|
||||
metadata["content"] = json.RawMessage(*fields.Content)
|
||||
}
|
||||
for key, include := range map[string]bool{
|
||||
"reference": options.IncludeReference, "prompt": options.IncludePrompt,
|
||||
"usage": options.IncludeUsage, "elapsed_ms": options.IncludeElapsedMs,
|
||||
} {
|
||||
if !include {
|
||||
delete(metadata, key)
|
||||
}
|
||||
}
|
||||
return json.Marshal(metadata)
|
||||
}
|
||||
|
||||
func messageValues(fields entity.ConversationMessageFields) map[string]interface{} {
|
||||
var reference interface{}
|
||||
if len(fields.Reference) > 0 {
|
||||
reference = string(fields.Reference)
|
||||
}
|
||||
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,
|
||||
"prompt": fields.Prompt, "usage": fields.Usage, "elapsed_ms": fields.ElapsedMs,
|
||||
"prompt": fields.Prompt, "usage": fields.Usage, "elapsed_ms": fields.ElapsedMs, "reference": reference,
|
||||
}
|
||||
}
|
||||
|
||||
@@ -363,84 +327,78 @@ func historyRows(kind, conversationID string, raw json.RawMessage) ([]map[string
|
||||
return rows, nil
|
||||
}
|
||||
|
||||
func loadHistory(ctx context.Context, db *gorm.DB, table, payloadColumn string, conversationIDs []string) (map[string]json.RawMessage, error) {
|
||||
// loadHistory excludes answer details unless the caller needs them for display.
|
||||
func loadHistory(ctx context.Context, db *gorm.DB, table string, conversationIDs []string, options ConversationHistoryOptions) (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))
|
||||
query := db.WithContext(ctx).Session(&gorm.Session{QueryFields: true}).Table(table).Where("conversation_id IN ?", conversationIDs).Order("conversation_id, position")
|
||||
var omitted []string
|
||||
for column, include := range map[string]bool{
|
||||
"reference": options.IncludeReference, "prompt": options.IncludePrompt,
|
||||
"usage": options.IncludeUsage, "elapsed_ms": options.IncludeElapsedMs,
|
||||
} {
|
||||
if !include {
|
||||
omitted = append(omitted, column)
|
||||
}
|
||||
}
|
||||
for _, conversationID := range conversationIDs {
|
||||
items := itemsByConversation[conversationID]
|
||||
if len(omitted) > 0 {
|
||||
query = query.Omit(omitted...)
|
||||
}
|
||||
var rows []entity.ConversationMessage
|
||||
if err := query.Find(&rows).Error; err != nil {
|
||||
return nil, err
|
||||
}
|
||||
itemsByConversation := make(map[string][]json.RawMessage, len(conversationIDs))
|
||||
for _, row := range rows {
|
||||
fields := row.ConversationMessageFields
|
||||
raw, err := marshalMessage(fields, options)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("marshal message for conversation %s: %w", row.ConversationID, err)
|
||||
}
|
||||
itemsByConversation[row.ConversationID] = append(itemsByConversation[row.ConversationID], raw)
|
||||
}
|
||||
for _, id := range conversationIDs {
|
||||
items := itemsByConversation[id]
|
||||
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)
|
||||
return nil, fmt.Errorf("marshal history for conversation %s: %w", id, err)
|
||||
}
|
||||
result[conversationID] = raw
|
||||
result[id] = raw
|
||||
}
|
||||
return result, nil
|
||||
}
|
||||
|
||||
func hydrateChatSessions(ctx context.Context, db *gorm.DB, sessions []*entity.ChatSession) error {
|
||||
func hydrateChatSessions(ctx context.Context, db *gorm.DB, sessions []*entity.ChatSession, options ConversationHistoryOptions) 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)
|
||||
messages, err := loadHistory(ctx, db, conversationMessageTable, ids, options)
|
||||
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 {
|
||||
func hydrateAPIConversations(ctx context.Context, db *gorm.DB, sessions []*entity.API4Conversation, options ConversationHistoryOptions) 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)
|
||||
messages, err := loadHistory(ctx, db, apiConversationMessageTable, ids, options)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for _, session := range sessions {
|
||||
session.Message = messages[session.ID]
|
||||
session.Reference = references[session.ID]
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -68,21 +68,15 @@ func BenchmarkConversationHistoryOperations(b *testing.B) {
|
||||
{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":[]}`)})
|
||||
entity.API4ConversationMessage{ConversationID: conversation.ID, Position: position + 1, ConversationMessageFields: entity.ConversationMessageFields{Role: &assistantRole, MessageID: &id, Content: &content, ContentType: "text", Metadata: "{}", 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)
|
||||
@@ -100,16 +94,13 @@ func BenchmarkConversationHistoryOperations(b *testing.B) {
|
||||
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}
|
||||
update = ConversationHistoryUpdate{Message: map[string]interface{}{"role": "assistant", "id": pendingID, "content": content, "created_at": 2.0, "reference": map[string]interface{}{"chunks": []interface{}{}}}, QuestionID: pendingID}
|
||||
}
|
||||
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)
|
||||
}
|
||||
@@ -146,7 +137,7 @@ func BenchmarkConversationHistoryOperations(b *testing.B) {
|
||||
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 {
|
||||
if err := db.Model(&entity.API4ConversationMessage{}).Where("conversation_id = ? AND reference IS NOT NULL", conversation.ID).Count(&referenceCount).Error; err != nil {
|
||||
b.Fatal(err)
|
||||
}
|
||||
if messageCount != int64(remaining) || referenceCount != int64((size-2)/2) {
|
||||
|
||||
@@ -25,6 +25,7 @@ import (
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
// conversationHistoryTargetVersion is the database version a completed split
|
||||
@@ -102,6 +103,9 @@ func migrateConversationHistory(ctx context.Context, db *gorm.DB) error {
|
||||
zap.String("target_version", conversationHistoryTargetVersion))
|
||||
return nil
|
||||
}
|
||||
if err := db.WithContext(ctx).AutoMigrate(&entity.ConversationReference{}, &entity.API4ConversationReference{}); err != nil {
|
||||
return fmt.Errorf("prepare reference tables for history split: %w", err)
|
||||
}
|
||||
splits := []conversationHistorySplit{
|
||||
{
|
||||
parentTable: "conversation",
|
||||
|
||||
@@ -116,7 +116,7 @@ func TestConversationMessageDetailsSchemaMigration(t *testing.T) {
|
||||
if _, ok := model.(*entity.API4ConversationMessage); ok {
|
||||
table, parent = apiConversationMessageTable, "api_4_conversation"
|
||||
}
|
||||
for _, column := range []string{"prompt", "usage", "elapsed_ms"} {
|
||||
for _, column := range []string{"prompt", "usage", "elapsed_ms", "reference"} {
|
||||
if err := db.Migrator().DropColumn(model, column); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -132,7 +132,7 @@ func TestConversationMessageDetailsSchemaMigration(t *testing.T) {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, column := range []string{"prompt", "usage", "elapsed_ms"} {
|
||||
for _, column := range []string{"prompt", "usage", "elapsed_ms", "reference"} {
|
||||
if !db.Migrator().HasColumn(model, column) {
|
||||
t.Fatalf("missing column %s", column)
|
||||
}
|
||||
@@ -141,7 +141,7 @@ func TestConversationMessageDetailsSchemaMigration(t *testing.T) {
|
||||
if err := db.Table(table).Where("conversation_id = ?", "session-1").Take(&row).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if row.Content == nil || *row.Content != "old answer" || row.Prompt != nil || len(row.Usage) != 0 || row.ElapsedMs != nil {
|
||||
if row.Content == nil || *row.Content != "old answer" || row.Prompt != nil || len(row.Usage) != 0 || row.ElapsedMs != nil || len(row.Reference) != 0 {
|
||||
t.Fatalf("migration changed old answer: %+v", row)
|
||||
}
|
||||
})
|
||||
|
||||
@@ -13,6 +13,8 @@ import (
|
||||
|
||||
func TestFlattenMessageRoundTrip(t *testing.T) {
|
||||
for _, item := range []string{
|
||||
`{"role":"assistant","content":"answer","reference":{"chunks":[{"id":"chunk-1"}],"doc_aggs":[]}}`,
|
||||
`{"role":"assistant","reference":{}}`,
|
||||
`{"role":"assistant","content":"agentic answer","usage":{"prompt_tokens":896,"completion_tokens":71,"total_tokens":967},"elapsed_ms":4168.774506}`,
|
||||
`{"role":"assistant","content":"answer","prompt":"system prompt","usage":{"prompt_tokens":896,"completion_tokens":71,"total_tokens":967},"elapsed_ms":4168.774506}`,
|
||||
`{"role":"assistant","prompt":"","usage":null,"elapsed_ms":0}`,
|
||||
@@ -27,7 +29,7 @@ func TestFlattenMessageRoundTrip(t *testing.T) {
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
raw, err := marshalMessage(fields)
|
||||
raw, err := marshalMessage(fields, ConversationHistoryOptions{IncludeReference: true, IncludePrompt: true, IncludeUsage: true, IncludeElapsedMs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
@@ -92,7 +94,7 @@ func TestFlattenedMessagePersistence(t *testing.T) {
|
||||
if agenticRow.Prompt != nil || !reflect.DeepEqual(agenticRow.Usage, row.Usage) || agenticRow.ElapsedMs == nil || *agenticRow.ElapsedMs != *row.ElapsedMs || agenticRow.Metadata != "{}" {
|
||||
t.Fatalf("unexpected agentic answer details: %+v", agenticRow.ConversationMessageFields)
|
||||
}
|
||||
histories, err := loadHistory(t.Context(), db, table, "message", []string{"session-1"})
|
||||
histories, err := loadHistory(t.Context(), db, table, []string{"session-1"}, ConversationHistoryOptions{IncludeReference: true, IncludePrompt: true, IncludeUsage: true, IncludeElapsedMs: true})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,190 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
|
||||
"go.uber.org/zap"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
)
|
||||
|
||||
const conversationReferenceTargetVersion = "v1.0.0-rc3.dev7"
|
||||
|
||||
// migrateConversationReferences runs after the history split and all earlier
|
||||
// versioned migrations. Source tables are dropped only after both copies succeed.
|
||||
func migrateConversationReferences(ctx context.Context, db *gorm.DB) error {
|
||||
version, err := GetDatabaseMigrationVersion(ctx, db)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if shouldSkipMigration(version, conversationReferenceTargetVersion) &&
|
||||
!db.WithContext(ctx).Migrator().HasTable(conversationReferenceTable) &&
|
||||
!db.WithContext(ctx).Migrator().HasTable(apiConversationReferenceTable) {
|
||||
return nil
|
||||
}
|
||||
for _, tables := range [][2]string{
|
||||
{conversationMessageTable, conversationReferenceTable},
|
||||
{apiConversationMessageTable, apiConversationReferenceTable},
|
||||
} {
|
||||
if err := migrateMessageReferences(ctx, db, tables[0], tables[1]); err != nil {
|
||||
return fmt.Errorf("migrate %s references: %w", tables[0], err)
|
||||
}
|
||||
if err := migrateMessageReferenceMetadata(ctx, db, tables[0]); err != nil {
|
||||
return fmt.Errorf("migrate %s reference metadata: %w", tables[0], err)
|
||||
}
|
||||
}
|
||||
for _, table := range []string{conversationReferenceTable, apiConversationReferenceTable} {
|
||||
if db.WithContext(ctx).Migrator().HasTable(table) {
|
||||
if err := db.WithContext(ctx).Migrator().DropTable(table); err != nil {
|
||||
return fmt.Errorf("drop migrated table %s: %w", table, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if shouldSkipMigration(version, conversationReferenceTargetVersion) {
|
||||
return nil
|
||||
}
|
||||
return setDatabaseMigrationVersion(ctx, db, conversationReferenceTargetVersion)
|
||||
}
|
||||
|
||||
func migrateMessageReferences(ctx context.Context, db *gorm.DB, messageTable, referenceTable string) error {
|
||||
db = db.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(referenceTable) {
|
||||
return nil
|
||||
}
|
||||
type referenceRow struct {
|
||||
ConversationID string
|
||||
Position int
|
||||
MessagePos *int `gorm:"column:message_position"`
|
||||
Reference string
|
||||
}
|
||||
lastConversation, activeConversation := "", ""
|
||||
lastPosition, lastAssistant := -1, -1
|
||||
unmatched := 0
|
||||
for {
|
||||
var rows []referenceRow
|
||||
if err := db.Table(referenceTable).
|
||||
Select("conversation_id", "position", "message_position", "reference").
|
||||
Where("conversation_id > ? OR (conversation_id = ? AND position > ?)", lastConversation, lastConversation, lastPosition).
|
||||
Order("conversation_id, position").Limit(conversationHistoryBatchSize).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
break
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, row := range rows {
|
||||
if row.ConversationID != activeConversation {
|
||||
activeConversation, lastAssistant = row.ConversationID, -1
|
||||
}
|
||||
if !json.Valid([]byte(row.Reference)) {
|
||||
return fmt.Errorf("invalid reference JSON for conversation %s position %d", row.ConversationID, row.Position)
|
||||
}
|
||||
position := row.MessagePos
|
||||
if position == nil {
|
||||
// Array references follow assistant order after the first user.
|
||||
// Explicit associations belong to later targeted writes and must
|
||||
// not consume an entry from the old reference array.
|
||||
var answer struct{ Position int }
|
||||
err := tx.Table(messageTable).Select("position").
|
||||
Where("conversation_id = ? AND role = ? AND position > ?", row.ConversationID, "assistant", lastAssistant).
|
||||
Where("position >= (SELECT MIN(position) FROM "+messageTable+" WHERE conversation_id = ? AND role = ?)", row.ConversationID, "user").
|
||||
Where("NOT EXISTS (SELECT 1 FROM "+referenceTable+" r WHERE r.conversation_id = ? AND r.message_position = "+messageTable+".position)", row.ConversationID).
|
||||
Order("position").Take(&answer).Error
|
||||
if errors.Is(err, gorm.ErrRecordNotFound) {
|
||||
unmatched++
|
||||
continue
|
||||
}
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
lastAssistant = answer.Position
|
||||
position = &answer.Position
|
||||
}
|
||||
query := tx.Table(messageTable).Where("conversation_id = ? AND position = ? AND role = ?", row.ConversationID, *position, "assistant")
|
||||
result := query.Session(&gorm.Session{}).Where("reference IS NULL OR reference = ? OR reference = ? OR TRIM(reference) LIKE ?", "", "null", "[%").Update("reference", row.Reference)
|
||||
if result.Error != nil {
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
var count int64
|
||||
if err := query.Count(&count).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if count == 0 {
|
||||
unmatched++
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
last := rows[len(rows)-1]
|
||||
lastConversation, lastPosition = last.ConversationID, last.Position
|
||||
}
|
||||
if unmatched > 0 {
|
||||
// References can outlive deleted messages or unsuccessful turns. They
|
||||
// cannot be attached to another answer without changing its citations.
|
||||
common.Warn("Skipped references without assistant messages during migration", zap.String("table", referenceTable), zap.Int("references", unmatched))
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// Older message rows can carry a second copy in metadata. Move that value when
|
||||
// there is no reference column value, then remove it so lean SQL reads stay lean.
|
||||
func migrateMessageReferenceMetadata(ctx context.Context, db *gorm.DB, table string) error {
|
||||
db = db.WithContext(ctx)
|
||||
if !db.Migrator().HasTable(table) {
|
||||
return nil
|
||||
}
|
||||
lastConversation, lastPosition := "", -1
|
||||
for {
|
||||
var rows []struct {
|
||||
ConversationID string
|
||||
Position int
|
||||
Metadata string
|
||||
}
|
||||
if err := db.Table(table).Select("conversation_id", "position", "metadata").
|
||||
Where("metadata LIKE ?", `%"reference"%`).
|
||||
Where("conversation_id > ? OR (conversation_id = ? AND position > ?)", lastConversation, lastConversation, lastPosition).
|
||||
Order("conversation_id, position").Limit(conversationHistoryBatchSize).Find(&rows).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if len(rows) == 0 {
|
||||
return nil
|
||||
}
|
||||
if err := db.Transaction(func(tx *gorm.DB) error {
|
||||
for _, row := range rows {
|
||||
var metadata map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(row.Metadata), &metadata); err != nil {
|
||||
return fmt.Errorf("decode metadata for conversation %s position %d: %w", row.ConversationID, row.Position, err)
|
||||
}
|
||||
reference, ok := metadata["reference"]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
delete(metadata, "reference")
|
||||
raw, err := json.Marshal(metadata)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
query := tx.Table(table).Where("conversation_id = ? AND position = ?", row.ConversationID, row.Position)
|
||||
if err := query.Session(&gorm.Session{}).Where("reference IS NULL OR reference = ? OR reference = ?", "", "null").Update("reference", string(reference)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
if err := query.Update("metadata", string(raw)).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
last := rows[len(rows)-1]
|
||||
lastConversation, lastPosition = last.ConversationID, last.Position
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,315 @@
|
||||
package dao
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/entity"
|
||||
)
|
||||
|
||||
func TestMigrateConversationReferences(t *testing.T) {
|
||||
for _, tables := range [][3]string{
|
||||
{"conversation", conversationMessageTable, conversationReferenceTable},
|
||||
{"api_4_conversation", apiConversationMessageTable, apiConversationReferenceTable},
|
||||
} {
|
||||
t.Run(tables[0], func(t *testing.T) {
|
||||
db := setupConversationHistoryMigrationDB(t, false)
|
||||
insertConversation(t, db, tables[0], "session")
|
||||
messages := []map[string]interface{}{
|
||||
{"role": "system", "content": "system"},
|
||||
{"role": "assistant", "content": "prologue"},
|
||||
{"role": "user", "id": "unanswered"},
|
||||
}
|
||||
// Cross a reference batch boundary within the same conversation.
|
||||
for i := 0; i < conversationHistoryBatchSize+3; i++ {
|
||||
messages = append(messages, map[string]interface{}{"role": "user"}, map[string]interface{}{"role": "assistant"})
|
||||
}
|
||||
messages = append(messages,
|
||||
map[string]interface{}{"role": "user", "id": "delayed"},
|
||||
map[string]interface{}{"role": "assistant", "id": "delayed"},
|
||||
map[string]interface{}{"role": "user", "id": "later"},
|
||||
map[string]interface{}{"role": "assistant", "id": "later", "reference": map[string]interface{}{"chunks": []interface{}{}, "saved": true}},
|
||||
)
|
||||
raw, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := createHistory(t.Context(), db, tables[1], "message", "session", raw); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i := 0; i < conversationHistoryBatchSize+3; i++ {
|
||||
if err := db.Table(tables[2]).Create(map[string]interface{}{
|
||||
"conversation_id": "session", "position": i, "reference": fmt.Sprintf(`{"source":%d}`, i),
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
// Completion order differs from message order; message_position wins.
|
||||
for i, position := range []int{len(messages) - 1, len(messages) - 3} {
|
||||
if err := db.Table(tables[2]).Create(map[string]interface{}{
|
||||
"conversation_id": "session", "position": 500 + i, "message_position": position, "reference": `{"explicit":true}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if err := migrateMessageReferences(t.Context(), db, tables[1], tables[2]); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 2 {
|
||||
// Retry copied rows, then retry after table deletion before the marker.
|
||||
writeDatabaseVersion(t, db, dslVersionTargetVersion)
|
||||
if err := migrateConversationReferences(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := databaseVersion(t, db); got != conversationReferenceTargetVersion {
|
||||
t.Fatalf("version = %s", got)
|
||||
}
|
||||
for _, table := range []string{conversationReferenceTable, apiConversationReferenceTable} {
|
||||
if db.Migrator().HasTable(table) {
|
||||
t.Fatalf("source table still exists: %s", table)
|
||||
}
|
||||
}
|
||||
var rows []entity.ConversationMessage
|
||||
if err := db.Table(tables[1]).Order("position").Find(&rows).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, row := range rows {
|
||||
want := ""
|
||||
if i >= 4 && i < len(messages)-4 && i%2 == 0 {
|
||||
want = fmt.Sprintf(`{"source":%d}`, (i-4)/2)
|
||||
} else if i == len(messages)-3 {
|
||||
want = `{"explicit":true}`
|
||||
} else if i == len(messages)-1 {
|
||||
want = `{"chunks":[],"saved":true}`
|
||||
}
|
||||
if string(row.Reference) != want {
|
||||
t.Fatalf("message %d reference = %s, want %s", i, row.Reference, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
writeDatabaseVersion(t, db, "v1.0.0-rc3.dev8")
|
||||
if err := migrateConversationReferences(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := databaseVersion(t, db); got != "v1.0.0-rc3.dev8" {
|
||||
t.Fatalf("downgraded marker: %s", got)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationReferenceCleanupForMigratedDatabase(t *testing.T) {
|
||||
for _, version := range []string{conversationReferenceTargetVersion, "v1.0.0-rc3.dev8"} {
|
||||
t.Run(version, func(t *testing.T) {
|
||||
db := setupConversationHistoryMigrationDB(t, false)
|
||||
insertConversation(t, db, "conversation", "session")
|
||||
if err := createHistory(t.Context(), db, conversationMessageTable, "message", "session", json.RawMessage(`[{"role":"user"},{"role":"assistant"}]`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table(conversationReferenceTable).Create(map[string]interface{}{
|
||||
"conversation_id": "session", "position": 0, "reference": `{"copied":true}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDatabaseVersion(t, db, version)
|
||||
if err := migrateConversationReferences(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if got := databaseVersion(t, db); got != version {
|
||||
t.Fatalf("changed migration version: %s", got)
|
||||
}
|
||||
var answer entity.ConversationMessage
|
||||
if err := db.Where("conversation_id = ? AND position = ?", "session", 1).Take(&answer).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if string(answer.Reference) != `{"copied":true}` {
|
||||
t.Fatalf("version marker prevented copying: %s", answer.Reference)
|
||||
}
|
||||
if err := autoMigrateRuntimeModels(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{conversationReferenceTable, apiConversationReferenceTable} {
|
||||
if db.Migrator().HasTable(table) {
|
||||
t.Fatalf("server recreated source table: %s", table)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestConversationReferenceCleanupSkipsUnmatchedReferences(t *testing.T) {
|
||||
db := setupConversationHistoryMigrationDB(t, false)
|
||||
insertConversation(t, db, "api_4_conversation", "unmatched")
|
||||
insertConversation(t, db, "conversation", "surviving")
|
||||
if err := createHistory(t.Context(), db, conversationMessageTable, "message", "surviving", json.RawMessage(`[{"role":"user"},{"role":"assistant"}]`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
missingPosition := 9
|
||||
for _, row := range []struct {
|
||||
table, conversationID string
|
||||
position int
|
||||
messagePosition *int
|
||||
reference string
|
||||
}{
|
||||
{apiConversationReferenceTable, "unmatched", 0, nil, `{"chunks":[{"id":"empty-history"}]}`},
|
||||
{conversationReferenceTable, "surviving", 0, nil, `{"chunks":[{"id":"surviving"}]}`},
|
||||
{conversationReferenceTable, "surviving", 1, nil, `{"chunks":[{"id":"surplus"}]}`},
|
||||
{conversationReferenceTable, "surviving", 9, &missingPosition, `{"chunks":[{"id":"missing-position"}]}`},
|
||||
} {
|
||||
if err := db.Table(row.table).Create(map[string]interface{}{
|
||||
"conversation_id": row.conversationID, "position": row.position, "message_position": row.messagePosition, "reference": row.reference,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
writeDatabaseVersion(t, db, dslVersionTargetVersion)
|
||||
if err := migrateConversationReferences(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, table := range []string{conversationReferenceTable, apiConversationReferenceTable} {
|
||||
if db.Migrator().HasTable(table) {
|
||||
t.Fatalf("stale references prevented source cleanup: %s", table)
|
||||
}
|
||||
}
|
||||
if got := databaseVersion(t, db); got != conversationReferenceTargetVersion {
|
||||
t.Fatalf("migration version = %s", got)
|
||||
}
|
||||
var messages []entity.ConversationMessage
|
||||
if err := db.Order("position").Find(&messages).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if len(messages) != 2 || string(messages[1].Reference) != `{"chunks":[{"id":"surviving"}]}` {
|
||||
t.Fatalf("stale references changed the surviving history: %+v", messages)
|
||||
}
|
||||
if err := migrateConversationReferences(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateConversationReferencesFailurePreservesVersion(t *testing.T) {
|
||||
db := setupConversationHistoryMigrationDB(t, false)
|
||||
insertConversation(t, db, "conversation", "session")
|
||||
if err := db.Table(conversationReferenceTable).Create(map[string]interface{}{
|
||||
"conversation_id": "session", "position": 0, "reference": "invalid JSON",
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
writeDatabaseVersion(t, db, dslVersionTargetVersion)
|
||||
if err := migrateConversationReferences(t.Context(), db); err == nil {
|
||||
t.Fatal("invalid reference accepted")
|
||||
}
|
||||
if got := databaseVersion(t, db); got != dslVersionTargetVersion {
|
||||
t.Fatalf("advanced version after failure: %s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestMigrateConversationReferenceMetadata(t *testing.T) {
|
||||
for _, tables := range [][3]string{
|
||||
{"conversation", conversationMessageTable, conversationReferenceTable},
|
||||
{"api_4_conversation", apiConversationMessageTable, apiConversationReferenceTable},
|
||||
} {
|
||||
t.Run(tables[0], func(t *testing.T) {
|
||||
db := setupConversationHistoryMigrationDB(t, false)
|
||||
insertConversation(t, db, tables[0], "session")
|
||||
if err := createHistory(t.Context(), db, tables[1], "message", "session", json.RawMessage(`[{"role":"user"},{"role":"assistant","reference":[{"old":"inline chunks"}]},{"role":"assistant"}]`)); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
// Existing flattened rows stored references in their metadata column.
|
||||
if err := db.Table(tables[1]).Where("conversation_id = ?", "session").Update("metadata", `{"reference":{"chunks":[]},"attachment":"file"}`).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Table(tables[2]).Create(map[string]interface{}{
|
||||
"conversation_id": "session", "position": 0, "reference": `{"chunks":[{"id":"source"}]}`,
|
||||
}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := migrateConversationReferences(t.Context(), db); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var messages []entity.ConversationMessage
|
||||
if err := db.Table(tables[1]).Order("position").Find(&messages).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for i, message := range messages {
|
||||
if message.Metadata != `{"attachment":"file"}` {
|
||||
t.Fatalf("metadata still contains reference: %s", message.Metadata)
|
||||
}
|
||||
want := `{"chunks":[]}`
|
||||
if i == 1 {
|
||||
want = `{"chunks":[{"id":"source"}]}`
|
||||
}
|
||||
if string(message.Reference) != want {
|
||||
t.Fatalf("message %d reference = %s, want %s", i, message.Reference, want)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestMessageReferencePersistenceAndProjection(t *testing.T) {
|
||||
for _, table := range []string{conversationMessageTable, apiConversationMessageTable} {
|
||||
t.Run(table, func(t *testing.T) {
|
||||
db := setupConversationHistoryMigrationDB(t, false)
|
||||
parent := "conversation"
|
||||
if table == apiConversationMessageTable {
|
||||
parent = "api_4_conversation"
|
||||
}
|
||||
insertConversation(t, db, parent, "session")
|
||||
for _, update := range []ConversationHistoryUpdate{
|
||||
{Message: map[string]interface{}{"id": "turn", "role": "user", "content": "question"}},
|
||||
{QuestionID: "turn", Message: map[string]interface{}{"id": "turn", "role": "assistant", "content": "answer", "prompt": "system prompt", "usage": map[string]interface{}{"total_tokens": 10}, "elapsed_ms": 50, "reference": map[string]interface{}{"chunks": []interface{}{map[string]interface{}{"id": "chunk"}}}}},
|
||||
} {
|
||||
if err := updateConversationHistory(t.Context(), db, table, "session", update); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
for _, options := range []ConversationHistoryOptions{
|
||||
{},
|
||||
{IncludeUsage: true, IncludeElapsedMs: true},
|
||||
{IncludeReference: true},
|
||||
{IncludePrompt: true},
|
||||
{IncludeReference: true, IncludePrompt: true, IncludeUsage: true, IncludeElapsedMs: true},
|
||||
} {
|
||||
var sql string
|
||||
capture := db.Session(&gorm.Session{DryRun: true})
|
||||
if err := capture.Callback().Query().After("gorm:query").Register("capture_history_sql", func(tx *gorm.DB) {
|
||||
sql = tx.Statement.SQL.String()
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := loadHistory(t.Context(), capture, table, []string{"session"}, options); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := capture.Callback().Query().Remove("capture_history_sql"); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for column, include := range map[string]bool{"reference": options.IncludeReference, "prompt": options.IncludePrompt, "usage": options.IncludeUsage, "elapsed_ms": options.IncludeElapsedMs} {
|
||||
if strings.Contains(sql, column) != include {
|
||||
t.Fatalf("options=%+v projection %s: %s", options, column, sql)
|
||||
}
|
||||
}
|
||||
if strings.Contains(sql, "SELECT *") || !strings.Contains(sql, "content") {
|
||||
t.Fatalf("invalid projection: %s", sql)
|
||||
}
|
||||
|
||||
history, err := loadHistory(t.Context(), db, table, []string{"session"}, options)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var messages []map[string]interface{}
|
||||
if err := json.Unmarshal(history["session"], &messages); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for key, include := range map[string]bool{"reference": options.IncludeReference, "prompt": options.IncludePrompt, "usage": options.IncludeUsage, "elapsed_ms": options.IncludeElapsedMs} {
|
||||
if _, ok := messages[1][key]; ok != include {
|
||||
t.Fatalf("options=%+v key %s in %s", options, key, history["session"])
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -167,12 +167,10 @@ func InitDB(ctx context.Context, migrateDB bool) error {
|
||||
&entity.ChatChannel{},
|
||||
&entity.ChatSession{},
|
||||
&entity.ConversationMessage{},
|
||||
&entity.ConversationReference{},
|
||||
&entity.Task{},
|
||||
&entity.APIToken{},
|
||||
&entity.API4Conversation{},
|
||||
&entity.API4ConversationMessage{},
|
||||
&entity.API4ConversationReference{},
|
||||
&entity.Knowledgebase{},
|
||||
&entity.InvitationCode{},
|
||||
&entity.Document{},
|
||||
@@ -275,7 +273,7 @@ func InitDB(ctx context.Context, migrateDB bool) error {
|
||||
}
|
||||
|
||||
// Add pipeline_version_id columns and lock existing dataflow bindings to a
|
||||
// version. Runs LAST so the shared migration marker only advances to
|
||||
// version. Runs after the lower-target migrations so the marker advances to
|
||||
// v1.0.0-rc3.dev6 after every lower-target migration (notably the
|
||||
// v1.0.0-rc3.dev5 tenant default-model column drop) has already run.
|
||||
if err = MigrateDSLVersionColumns(ctx, DB); err != nil {
|
||||
@@ -284,6 +282,9 @@ func InitDB(ctx context.Context, migrateDB bool) error {
|
||||
if err = BackfillDSLVersionBindings(ctx, DB); err != nil {
|
||||
return fmt.Errorf("failed to backfill DSL version bindings: %w", err)
|
||||
}
|
||||
if err = migrateConversationReferences(ctx, DB); err != nil {
|
||||
return fmt.Errorf("failed to move references into messages: %w", err)
|
||||
}
|
||||
common.Info("Database schema migrated successfully")
|
||||
} else {
|
||||
for _, column := range []string{"backlog_doc_ids", "inflight_doc_ids"} {
|
||||
@@ -394,9 +395,7 @@ func autoMigrateRuntimeModels(ctx context.Context, db *gorm.DB) error {
|
||||
&entity.KnowledgeCompileBacklog{},
|
||||
&entity.KnowledgeCompileInflight{},
|
||||
&entity.ConversationMessage{},
|
||||
&entity.ConversationReference{},
|
||||
&entity.API4ConversationMessage{},
|
||||
&entity.API4ConversationReference{},
|
||||
}
|
||||
for _, m := range goRuntimeModels {
|
||||
if err := autoMigrateSafely(ctx, db, m); err != nil {
|
||||
|
||||
@@ -18,8 +18,8 @@ package entity
|
||||
|
||||
import "encoding/json"
|
||||
|
||||
// API4Conversation API for conversation model. Message and Reference are
|
||||
// hydrated from their ordered child tables.
|
||||
// API4Conversation API for conversation model. Messages are
|
||||
// hydrated from the ordered message table.
|
||||
type API4Conversation struct {
|
||||
ID string `gorm:"column:id;primaryKey;size:32" json:"id"`
|
||||
Name *string `gorm:"column:name;size:255" json:"name,omitempty"`
|
||||
@@ -27,7 +27,6 @@ type API4Conversation struct {
|
||||
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:"-" 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"`
|
||||
|
||||
@@ -72,15 +72,14 @@ type ChatListItem struct {
|
||||
TenantAvatar *string `gorm:"column:tenant_avatar" json:"tenant_avatar,omitempty"`
|
||||
}
|
||||
|
||||
// ChatSession chat session model. Message and Reference are hydrated from
|
||||
// their ordered child tables.
|
||||
// ChatSession chat session model. Messages are hydrated from
|
||||
// the ordered message table.
|
||||
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:"-" json:"message,omitempty"`
|
||||
Reference json.RawMessage `gorm:"-" json:"reference"`
|
||||
UserID *string `gorm:"column:user_id;size:255;index" json:"user_id,omitempty"`
|
||||
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:"-" json:"message,omitempty"`
|
||||
UserID *string `gorm:"column:user_id;size:255;index" json:"user_id,omitempty"`
|
||||
BaseModel
|
||||
}
|
||||
|
||||
|
||||
@@ -20,18 +20,19 @@ 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"`
|
||||
Prompt *string `gorm:"column:prompt;type:longtext" json:"prompt,omitempty"`
|
||||
Usage JSONMap `gorm:"column:usage;type:longtext" json:"usage,omitempty"`
|
||||
ElapsedMs *float64 `gorm:"column:elapsed_ms" json:"elapsed_ms,omitempty"`
|
||||
Metadata string `gorm:"column:metadata;type:longtext" json:"-"`
|
||||
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"`
|
||||
Prompt *string `gorm:"column:prompt;type:longtext" json:"prompt,omitempty"`
|
||||
Usage JSONMap `gorm:"column:usage;type:longtext" json:"usage,omitempty"`
|
||||
ElapsedMs *float64 `gorm:"column:elapsed_ms" json:"elapsed_ms,omitempty"`
|
||||
Reference json.RawMessage `gorm:"column:reference;type:longtext;serializer:json" json:"reference,omitempty"`
|
||||
Metadata string `gorm:"column:metadata;type:longtext" json:"-"`
|
||||
}
|
||||
|
||||
// ConversationMessage stores one ordered message for a conversation.
|
||||
|
||||
+25
-13
@@ -989,19 +989,26 @@ func (h *AgentHandler) ListAgentSessions(c *gin.Context) {
|
||||
dslParam := c.Query("dsl")
|
||||
includeDSL := dslParam != "false" && dslParam != "False"
|
||||
ctx := c.Request.Context()
|
||||
options, err := sessionHistoryQuery(c)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeArgumentError, err.Error())
|
||||
return
|
||||
}
|
||||
resp, code, err := h.agentService.ListAgentSessions(ctx, user.ID, user.ID, canvasID, service.ListAgentSessionsRequest{
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Keywords: keywords,
|
||||
FromDate: fromDate,
|
||||
ToDate: toDate,
|
||||
OrderBy: orderby,
|
||||
Desc: desc,
|
||||
SessionID: sessionID,
|
||||
UserID: queryUserID,
|
||||
ExpUserID: expUserID,
|
||||
IncludeDSL: includeDSL,
|
||||
NoHistory: c.Query("include_history") == "false" || c.Query("include_history") == "False",
|
||||
Page: page,
|
||||
PageSize: pageSize,
|
||||
Keywords: keywords,
|
||||
FromDate: fromDate,
|
||||
ToDate: toDate,
|
||||
OrderBy: orderby,
|
||||
Desc: desc,
|
||||
SessionID: sessionID,
|
||||
UserID: queryUserID,
|
||||
ExpUserID: expUserID,
|
||||
IncludeDSL: includeDSL,
|
||||
IncludeReference: options.IncludeReference,
|
||||
IncludePrompt: options.IncludePrompt,
|
||||
NoHistory: c.Query("include_history") == "false" || c.Query("include_history") == "False",
|
||||
})
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
@@ -1052,7 +1059,12 @@ func (h *AgentHandler) GetAgentSession(c *gin.Context) {
|
||||
canvasID := c.Param("canvas_id")
|
||||
sessionID := c.Param("session_id")
|
||||
ctx := c.Request.Context()
|
||||
row, code, err := h.agentService.GetAgentSession(ctx, user.ID, canvasID, sessionID)
|
||||
options, err := sessionHistoryQuery(c)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeArgumentError, err.Error())
|
||||
return
|
||||
}
|
||||
row, code, err := h.agentService.GetAgentSession(ctx, user.ID, canvasID, sessionID, options)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, code, err.Error())
|
||||
return
|
||||
|
||||
@@ -28,6 +28,7 @@ import (
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
@@ -45,6 +46,21 @@ func NewChatSessionHandler(chatSessionService *service.ChatSessionService, userS
|
||||
}
|
||||
}
|
||||
|
||||
func sessionHistoryQuery(c *gin.Context) (dao.ConversationHistoryOptions, error) {
|
||||
var options dao.ConversationHistoryOptions
|
||||
for name, target := range map[string]*bool{
|
||||
"include_reference": &options.IncludeReference,
|
||||
"include_prompt": &options.IncludePrompt,
|
||||
} {
|
||||
value, err := strconv.ParseBool(c.DefaultQuery(name, "false"))
|
||||
if err != nil {
|
||||
return options, fmt.Errorf("%s must be a boolean", name)
|
||||
}
|
||||
*target = value
|
||||
}
|
||||
return options, nil
|
||||
}
|
||||
|
||||
// ListChatSessions list chat sessions for a dialog
|
||||
// @Summary List Chat Sessions
|
||||
// @Description Get list of chat sessions for a specific dialog
|
||||
@@ -110,7 +126,13 @@ func (h *ChatSessionHandler) ListChatSessions(c *gin.Context) {
|
||||
// Call service to list chat sessions
|
||||
ctx := c.Request.Context()
|
||||
includeHistory := c.DefaultQuery("include_history", "true")
|
||||
result, err := h.chatSessionService.ListChatSessions(ctx, userID, chatID, c.Query("id"), c.Query("name"), terms, page, pageSize, includeHistory != "false" && includeHistory != "False")
|
||||
options, err := sessionHistoryQuery(c)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeArgumentError, err.Error())
|
||||
return
|
||||
}
|
||||
options.NoHistory = includeHistory == "false" || includeHistory == "False"
|
||||
result, err := h.chatSessionService.ListChatSessions(ctx, userID, chatID, c.Query("id"), c.Query("name"), terms, page, pageSize, options)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
@@ -314,7 +336,12 @@ func (h *ChatSessionHandler) GetSession(c *gin.Context) {
|
||||
chatID, sessionID := c.Param("chat_id"), c.Param("session_id")
|
||||
|
||||
ctx := c.Request.Context()
|
||||
result, code, err := h.chatSessionService.GetSession(ctx, userID, chatID, sessionID)
|
||||
options, err := sessionHistoryQuery(c)
|
||||
if err != nil {
|
||||
common.ErrorWithCode(c, common.CodeArgumentError, err.Error())
|
||||
return
|
||||
}
|
||||
result, code, err := h.chatSessionService.GetSession(ctx, userID, chatID, sessionID, options)
|
||||
if err != nil {
|
||||
if respondPermissionErrorIf(c, err, true) {
|
||||
return
|
||||
|
||||
@@ -0,0 +1,167 @@
|
||||
package handler
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"gorm.io/gorm"
|
||||
|
||||
"ragflow/internal/common"
|
||||
"ragflow/internal/dao"
|
||||
"ragflow/internal/entity"
|
||||
"ragflow/internal/service"
|
||||
)
|
||||
|
||||
func TestSessionHistoryQueryFlags(t *testing.T) {
|
||||
for _, endpoint := range []string{"chat", "chat_list", "agent", "agent_list", "create", "update", "delete_message", "feedback"} {
|
||||
for _, flags := range []struct {
|
||||
query string
|
||||
reference bool
|
||||
prompt bool
|
||||
invalid bool
|
||||
}{
|
||||
{},
|
||||
{query: "include_reference=false&include_prompt=false"},
|
||||
{query: "include_reference=true", reference: true},
|
||||
{query: "include_prompt=true", prompt: true},
|
||||
{query: "include_reference=true&include_prompt=true", reference: true, prompt: true},
|
||||
{query: "include_reference=invalid", invalid: true},
|
||||
{query: "include_prompt=invalid", invalid: true},
|
||||
} {
|
||||
t.Run(endpoint+"/"+flags.query, func(t *testing.T) {
|
||||
mutation := endpoint == "create" || endpoint == "update" || endpoint == "delete_message" || endpoint == "feedback"
|
||||
c, w, db := setupGinContextWithUserAndDB(t, http.MethodGet, "/?"+flags.query)
|
||||
if mutation {
|
||||
body := `{"name":"session"}`
|
||||
if endpoint == "feedback" {
|
||||
body = `{"thumbup":true}`
|
||||
}
|
||||
c.Request.Body = io.NopCloser(strings.NewReader(body))
|
||||
}
|
||||
if err := db.AutoMigrate(&entity.Chat{}, &entity.ChatSession{}, &entity.ConversationMessage{}, &entity.API4ConversationMessage{}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&entity.Chat{ID: "chat", TenantID: "user-1", LLMID: "model", LLMSetting: entity.JSONMap{}, PromptConfig: entity.JSONMap{}, KBIDs: entity.JSONSlice{}, DoRefer: "1", Status: sptr("1")}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if err := db.Create(&entity.UserCanvas{ID: "agent", UserID: "user-1"}).Error; err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
message := json.RawMessage(`[{"id":"answer","role":"assistant","content":"answer","reference":{"chunks":[],"doc_aggs":[]},"prompt":"large prompt","usage":{"total_tokens":1},"elapsed_ms":50}]`)
|
||||
if err := dao.NewChatSessionDAO().Create(c.Request.Context(), db, &entity.ChatSession{ID: "session", DialogID: "chat", UserID: sptr("user-1"), Message: message}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if mutation {
|
||||
for _, update := range []dao.ConversationHistoryUpdate{
|
||||
{Message: map[string]interface{}{"id": "turn", "role": "user", "content": "question"}},
|
||||
{QuestionID: "turn", Message: map[string]interface{}{"id": "turn", "role": "assistant", "content": "answer", "prompt": "large prompt", "reference": map[string]interface{}{"chunks": []interface{}{}}}},
|
||||
} {
|
||||
if err := dao.NewChatSessionDAO().UpdateByID(c.Request.Context(), db, "session", map[string]interface{}{"history_update": update}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
}
|
||||
if err := dao.NewAPI4ConversationDAO().Create(c.Request.Context(), db, &entity.API4Conversation{ID: "session", DialogID: "agent", UserID: "user-1", Message: message}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var historySQL string
|
||||
if err := db.Callback().Query().After("gorm:query").Register("capture_session_history", func(tx *gorm.DB) {
|
||||
sql := tx.Statement.SQL.String()
|
||||
if strings.Contains(sql, "`content`") && (strings.Contains(sql, "FROM `conversation_message`") || strings.Contains(sql, "FROM `api_4_conversation_message`")) {
|
||||
historySQL = sql
|
||||
}
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
chat := NewChatSessionHandler(service.NewChatSessionService(), nil)
|
||||
agent := NewAgentHandler(c.Request.Context(), service.NewAgentService(), nil)
|
||||
c.Params = gin.Params{{Key: "chat_id", Value: "chat"}, {Key: "canvas_id", Value: "agent"}, {Key: "session_id", Value: "session"}, {Key: "msg_id", Value: "turn"}}
|
||||
switch endpoint {
|
||||
case "chat":
|
||||
chat.GetSession(c)
|
||||
case "chat_list":
|
||||
chat.ListChatSessions(c)
|
||||
case "agent":
|
||||
agent.GetAgentSession(c)
|
||||
case "agent_list":
|
||||
agent.ListAgentSessions(c)
|
||||
case "create":
|
||||
chat.CreateSession(c)
|
||||
case "update":
|
||||
chat.UpdateSession(c)
|
||||
case "delete_message":
|
||||
chat.DeleteSessionMessage(c)
|
||||
case "feedback":
|
||||
chat.UpdateMessageFeedback(c)
|
||||
}
|
||||
var response struct {
|
||||
Code common.ErrorCode `json:"code"`
|
||||
Data json.RawMessage `json:"data"`
|
||||
}
|
||||
if err := json.Unmarshal(w.Body.Bytes(), &response); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if flags.invalid && !mutation {
|
||||
if response.Code != common.CodeArgumentError || historySQL != "" {
|
||||
t.Fatalf("invalid flags should fail before history SQL: %s", w.Body.String())
|
||||
}
|
||||
return
|
||||
}
|
||||
if response.Code != common.CodeSuccess {
|
||||
t.Fatalf("session request failed: %s", w.Body.String())
|
||||
}
|
||||
if mutation {
|
||||
var session struct {
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
}
|
||||
if err := json.Unmarshal(response.Data, &session); err != nil || len(session.Messages) == 0 {
|
||||
t.Fatalf("invalid mutation response: %s, %v", response.Data, err)
|
||||
}
|
||||
for _, message := range session.Messages {
|
||||
for _, field := range []string{"reference", "prompt"} {
|
||||
if _, exists := message[field]; exists {
|
||||
t.Fatalf("mutation returned %s: %s", field, response.Data)
|
||||
}
|
||||
}
|
||||
}
|
||||
if strings.Contains(historySQL, "`prompt`") || strings.Contains(historySQL, "`reference`") != (endpoint == "feedback") {
|
||||
t.Fatalf("mutation loaded unneeded large fields: %s", historySQL)
|
||||
}
|
||||
return
|
||||
}
|
||||
data := response.Data
|
||||
if strings.HasSuffix(endpoint, "_list") {
|
||||
var rows []json.RawMessage
|
||||
if err := json.Unmarshal(data, &rows); err != nil || len(rows) != 1 {
|
||||
t.Fatalf("invalid session list: %s, %v", data, err)
|
||||
}
|
||||
data = rows[0]
|
||||
}
|
||||
var session map[string]json.RawMessage
|
||||
if err := json.Unmarshal(data, &session); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
key := "messages"
|
||||
if endpoint != "chat" {
|
||||
key = "message"
|
||||
}
|
||||
var messages []map[string]interface{}
|
||||
if err := json.Unmarshal(session[key], &messages); err != nil || len(messages) != 1 {
|
||||
t.Fatalf("invalid messages: %s, %v", session[key], err)
|
||||
}
|
||||
for field, include := range map[string]bool{"reference": flags.reference, "prompt": flags.prompt, "usage": true, "elapsed_ms": true} {
|
||||
if _, exists := messages[0][field]; exists != include {
|
||||
t.Fatalf("unexpected %s in response: %s", field, w.Body.String())
|
||||
}
|
||||
if strings.Contains(historySQL, "`"+field+"`") != include {
|
||||
t.Fatalf("unexpected %s in SQL: %s", field, historySQL)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -2574,7 +2574,6 @@ func (s *AgentService) createAgentRunSession(
|
||||
UserID: userID,
|
||||
ExpUserID: &userID,
|
||||
Message: json.RawMessage(`[]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
Source: &source,
|
||||
DSL: entity.JSONMap(runDSL),
|
||||
}
|
||||
@@ -2702,12 +2701,12 @@ func (s *AgentService) persistAgentRunSession(
|
||||
if sessionID == "" || s == nil || s.api4ConversationDAO == nil || dao.DB == nil {
|
||||
return nil
|
||||
}
|
||||
history := dao.ConversationHistoryUpdate{Reference: normalizeAgentReferenceEntry(reference), AppendReference: true}
|
||||
history := dao.ConversationHistoryUpdate{}
|
||||
if stringifyAgentUserInput(agentRunQuery(userInput)) != "" {
|
||||
history.QuestionID = messageID
|
||||
}
|
||||
if appendAssistantMessage {
|
||||
history.Message = map[string]interface{}{"role": "assistant", "content": agentSessionMessageContent(answer, thinking), "id": messageID, "created_at": now}
|
||||
history.Message = map[string]interface{}{"role": "assistant", "content": agentSessionMessageContent(answer, thinking), "id": messageID, "created_at": now, "reference": normalizeAgentReferenceEntry(reference)}
|
||||
}
|
||||
updates := map[string]interface{}{
|
||||
"round": gorm.Expr("COALESCE(round, 0) + 1"),
|
||||
|
||||
@@ -83,11 +83,10 @@ func TestCancelSessionRunFinishedSessionDoesNotCreateCancelMarker(t *testing.T)
|
||||
testDB := setupServiceTestDB(t)
|
||||
pushServiceDB(t, testDB)
|
||||
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(`[]`),
|
||||
ID: "session-persisted",
|
||||
DialogID: "agent-1",
|
||||
UserID: "user-a",
|
||||
Message: json.RawMessage(`[]`),
|
||||
}); err != nil {
|
||||
t.Fatalf("create conversation: %v", err)
|
||||
}
|
||||
|
||||
@@ -39,11 +39,10 @@ func TestAgentRunSessionUpdateFailurePreventsSuccessEvents(t *testing.T) {
|
||||
t.Cleanup(func() { dao.DB = originalDB })
|
||||
|
||||
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(`[]`),
|
||||
ID: "session-update-failure",
|
||||
DialogID: "canvas-update-failure",
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
@@ -128,11 +127,10 @@ func TestPersistAgentRunSessionPreservesThinking(t *testing.T) {
|
||||
t.Cleanup(func() { dao.DB = originalDB })
|
||||
|
||||
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(`[]`),
|
||||
ID: "session-think",
|
||||
DialogID: "canvas-think",
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
@@ -145,7 +143,7 @@ func TestPersistAgentRunSessionPreservesThinking(t *testing.T) {
|
||||
t.Fatalf("persist: %v", err)
|
||||
}
|
||||
|
||||
conv, err := dao.NewAPI4ConversationDAO().GetByID(t.Context(), testDB, "session-think")
|
||||
conv, err := dao.NewAPI4ConversationDAO().GetByID(t.Context(), testDB, "session-think", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil {
|
||||
t.Fatalf("reload session: %v", err)
|
||||
}
|
||||
@@ -222,13 +220,13 @@ func TestPersistAgentRunSessionPreservesThinking(t *testing.T) {
|
||||
t.Fatalf("concurrent run: %v", err)
|
||||
}
|
||||
}
|
||||
conv, err = dao.NewAPI4ConversationDAO().GetByID(ctx, testDB, "session-think")
|
||||
conv, err = dao.NewAPI4ConversationDAO().GetByID(ctx, testDB, "session-think", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
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)
|
||||
if len(messages) != 6 || messages[1]["reference"] == nil || messages[3]["reference"] == nil || messages[5]["reference"] == nil {
|
||||
t.Fatalf("concurrent turns were lost: messages=%s", conv.Message)
|
||||
}
|
||||
for _, index := range []int{2, 4} {
|
||||
if messages[index]["role"] != "user" || messages[index+1]["role"] != "assistant" || messages[index]["id"] != messages[index+1]["id"] {
|
||||
|
||||
@@ -287,11 +287,10 @@ func TestRunAgent_SessionHistoryFeedsSysHistoryAndPersists(t *testing.T) {
|
||||
}
|
||||
makeCanvasWithDSL(t, "canvas-history", "user-1", "tenant-1", "v-history", dsl)
|
||||
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(`[]`),
|
||||
ID: "session-history",
|
||||
DialogID: "canvas-history",
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
@@ -472,12 +471,11 @@ 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 := 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),
|
||||
ID: "session-foreign",
|
||||
DialogID: "canvas-session-owner",
|
||||
UserID: "user-2",
|
||||
Message: foreignMessage,
|
||||
DSL: entity.JSONMap(dsl),
|
||||
}); err != nil {
|
||||
t.Fatalf("create foreign session: %v", err)
|
||||
}
|
||||
@@ -571,12 +569,11 @@ func TestRunAgent_RealCanvas_WaitForUserResume(t *testing.T) {
|
||||
}
|
||||
makeCanvasWithDSL(t, "canvas-fillup", "user-1", "tenant-1", "v-fillup", dsl)
|
||||
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),
|
||||
ID: "session-fillup",
|
||||
DialogID: "canvas-fillup",
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
DSL: entity.JSONMap(dsl),
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
@@ -708,12 +705,11 @@ func TestRunAgent_InterruptPersistsPartialAssistantHistory(t *testing.T) {
|
||||
}
|
||||
makeCanvasWithDSL(t, "canvas-partial", "user-1", "tenant-1", "v-partial", dsl)
|
||||
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),
|
||||
ID: "session-partial",
|
||||
DialogID: "canvas-partial",
|
||||
UserID: "user-1",
|
||||
Message: json.RawMessage(`[]`),
|
||||
DSL: entity.JSONMap(dsl),
|
||||
}); err != nil {
|
||||
t.Fatalf("create session: %v", err)
|
||||
}
|
||||
|
||||
@@ -23,7 +23,6 @@ import (
|
||||
"fmt"
|
||||
"ragflow/internal/utility"
|
||||
"sort"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -41,18 +40,20 @@ const (
|
||||
|
||||
// ListAgentSessionsRequest are the parameters for ListAgentSessions.
|
||||
type ListAgentSessionsRequest struct {
|
||||
SessionID string
|
||||
UserID string
|
||||
Page int
|
||||
PageSize int
|
||||
Keywords string
|
||||
FromDate string
|
||||
ToDate string
|
||||
OrderBy string
|
||||
Desc bool
|
||||
ExpUserID string
|
||||
IncludeDSL bool
|
||||
NoHistory bool
|
||||
SessionID string
|
||||
UserID string
|
||||
Page int
|
||||
PageSize int
|
||||
Keywords string
|
||||
FromDate string
|
||||
ToDate string
|
||||
OrderBy string
|
||||
Desc bool
|
||||
ExpUserID string
|
||||
IncludeDSL bool
|
||||
NoHistory bool
|
||||
IncludeReference bool
|
||||
IncludePrompt bool
|
||||
}
|
||||
|
||||
// ListAgentSessionsResponse is the response body for ListAgentSessions.
|
||||
@@ -118,26 +119,6 @@ func parseAgentSessionDate(value string, isEnd bool) (*time.Time, error) {
|
||||
|
||||
func normalizeAgentSession(session *entity.API4Conversation, includeDSL bool) map[string]interface{} {
|
||||
messages := parseAgentSessionMessages(session.Message)
|
||||
references := parseAgentSessionReferences(session.Reference)
|
||||
|
||||
for _, message := range messages {
|
||||
delete(message, "prompt")
|
||||
}
|
||||
|
||||
if len(references) > 0 {
|
||||
assistantMessages := make([]map[string]interface{}, 0)
|
||||
for i, message := range messages {
|
||||
role, _ := message["role"].(string)
|
||||
if i != 0 && role != "user" {
|
||||
assistantMessages = append(assistantMessages, message)
|
||||
}
|
||||
}
|
||||
|
||||
for i := 0; i < len(assistantMessages) && i < len(references); i++ {
|
||||
rawChunks, _ := references[i]["chunks"].([]interface{})
|
||||
assistantMessages[i]["reference"] = normalizeAgentReferenceChunks(rawChunks)
|
||||
}
|
||||
}
|
||||
|
||||
result := map[string]interface{}{
|
||||
"id": session.ID,
|
||||
@@ -164,48 +145,6 @@ func normalizeAgentSession(session *entity.API4Conversation, includeDSL bool) ma
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAgentSessionReferences(raw json.RawMessage) []map[string]interface{} {
|
||||
if len(raw) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
|
||||
var references []map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &references); err == nil {
|
||||
for i, reference := range references {
|
||||
references[i] = normalizeAgentReferenceEntry(reference)
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
var referenceMap map[string]interface{}
|
||||
if err := json.Unmarshal(raw, &referenceMap); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
if _, ok := referenceMap["chunks"]; ok {
|
||||
return []map[string]interface{}{normalizeAgentReferenceEntry(referenceMap)}
|
||||
}
|
||||
|
||||
keys := make([]string, 0, len(referenceMap))
|
||||
for key := range referenceMap {
|
||||
keys = append(keys, key)
|
||||
}
|
||||
sort.Slice(keys, func(i, j int) bool {
|
||||
left, _ := strconv.Atoi(keys[i])
|
||||
right, _ := strconv.Atoi(keys[j])
|
||||
return left < right
|
||||
})
|
||||
|
||||
result := make([]map[string]interface{}, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
reference, ok := referenceMap[key].(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
result = append(result, normalizeAgentReferenceEntry(reference))
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func parseAgentSessionMessages(raw json.RawMessage) []map[string]interface{} {
|
||||
if len(raw) == 0 {
|
||||
return []map[string]interface{}{}
|
||||
@@ -214,6 +153,12 @@ func parseAgentSessionMessages(raw json.RawMessage) []map[string]interface{} {
|
||||
if err := json.Unmarshal(raw, &messages); err != nil {
|
||||
return []map[string]interface{}{}
|
||||
}
|
||||
for _, message := range messages {
|
||||
if reference, ok := message["reference"].(map[string]interface{}); ok {
|
||||
chunks, _ := reference["chunks"].([]interface{})
|
||||
reference["chunks"] = normalizeAgentReferenceChunks(chunks)
|
||||
}
|
||||
}
|
||||
return messages
|
||||
}
|
||||
|
||||
@@ -342,19 +287,20 @@ func (s *AgentService) ListAgentSessions(ctx context.Context, userID, _ string,
|
||||
}
|
||||
|
||||
total, sessions, err := sessionDAO.ListAgentSessions(ctx, dao.DB, dao.ListAgentSessionsParams{
|
||||
AgentID: agentID,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
OrderBy: req.OrderBy,
|
||||
Desc: req.Desc,
|
||||
SessionID: req.SessionID,
|
||||
UserID: req.UserID,
|
||||
IncludeDSL: req.IncludeDSL,
|
||||
NoHistory: req.NoHistory,
|
||||
Keywords: req.Keywords,
|
||||
FromDate: fromDate,
|
||||
ToDate: toDate,
|
||||
ExpUserID: req.ExpUserID,
|
||||
AgentID: agentID,
|
||||
Page: req.Page,
|
||||
PageSize: req.PageSize,
|
||||
OrderBy: req.OrderBy,
|
||||
Desc: req.Desc,
|
||||
SessionID: req.SessionID,
|
||||
UserID: req.UserID,
|
||||
IncludeDSL: req.IncludeDSL,
|
||||
NoHistory: req.NoHistory,
|
||||
HistoryOptions: sessionHistoryOptions(dao.ConversationHistoryOptions{IncludeReference: req.IncludeReference, IncludePrompt: req.IncludePrompt}),
|
||||
Keywords: req.Keywords,
|
||||
FromDate: fromDate,
|
||||
ToDate: toDate,
|
||||
ExpUserID: req.ExpUserID,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
@@ -371,7 +317,7 @@ func (s *AgentService) ListAgentSessions(ctx context.Context, userID, _ string,
|
||||
}
|
||||
|
||||
// GetAgentSession fetches a single conversation belonging to agentID.
|
||||
func (s *AgentService) GetAgentSession(ctx context.Context, userID, agentID, sessionID string) (*entity.API4Conversation, common.ErrorCode, error) {
|
||||
func (s *AgentService) GetAgentSession(ctx context.Context, userID, agentID, sessionID string, options ...dao.ConversationHistoryOptions) (*entity.API4Conversation, common.ErrorCode, error) {
|
||||
if sessionID == "" {
|
||||
return nil, common.CodeArgumentError, fmt.Errorf("session_id is required")
|
||||
}
|
||||
@@ -386,13 +332,17 @@ func (s *AgentService) GetAgentSession(ctx context.Context, userID, agentID, ses
|
||||
return nil, common.CodeOperatingError, errors.New("agent not found or no permission")
|
||||
}
|
||||
|
||||
data, err := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, agentID)
|
||||
data, err := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, agentID, sessionHistoryOptions(options...))
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("failed to fetch session: %w", err)
|
||||
}
|
||||
if data == nil {
|
||||
return nil, common.CodeNotFound, fmt.Errorf("agent session not found")
|
||||
}
|
||||
data.Message, err = json.Marshal(parseAgentSessionMessages(data.Message))
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, fmt.Errorf("format session messages: %w", err)
|
||||
}
|
||||
return data, common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
@@ -409,7 +359,7 @@ func (s *AgentService) DeleteAgentSessionItem(ctx context.Context, userID, agent
|
||||
return false, common.CodeServerError, fmt.Errorf("failed to check agent access: %w", err)
|
||||
}
|
||||
|
||||
conv, cerr := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, agentID)
|
||||
conv, cerr := s.api4ConversationDAO.GetMetadataBySessionID(ctx, dao.DB, sessionID, agentID)
|
||||
if cerr != nil {
|
||||
return false, common.CodeServerError, cerr
|
||||
}
|
||||
@@ -467,7 +417,7 @@ func (s *AgentService) DeleteAgentSessions(ctx context.Context, userID, agentID
|
||||
continue
|
||||
}
|
||||
|
||||
conv, err := s.api4ConversationDAO.GetBySessionID(ctx, dao.DB, sessionID, agentID)
|
||||
conv, err := s.api4ConversationDAO.GetMetadataBySessionID(ctx, dao.DB, sessionID, agentID)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
@@ -651,7 +601,6 @@ type CreateAgentSessionRequest struct {
|
||||
// - dialog_id : agent canvas id
|
||||
// - user_id : caller's id
|
||||
// - 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
|
||||
@@ -686,7 +635,6 @@ func (s *AgentService) CreateAgentSession(ctx context.Context, req *CreateAgentS
|
||||
if len(messages) == 0 {
|
||||
messages = json.RawMessage(`[]`)
|
||||
}
|
||||
reference := json.RawMessage(`[]`)
|
||||
|
||||
var dsl entity.JSONMap
|
||||
if len(req.DSL) > 0 {
|
||||
@@ -730,7 +678,6 @@ func (s *AgentService) CreateAgentSession(ctx context.Context, req *CreateAgentS
|
||||
UserID: req.UserID,
|
||||
ExpUserID: &req.UserID,
|
||||
Message: messages,
|
||||
Reference: reference,
|
||||
Source: sourcePtr,
|
||||
DSL: dsl,
|
||||
}
|
||||
|
||||
@@ -948,11 +948,10 @@ func createAgentSessionTestConversation(t *testing.T, id, agentID, userID string
|
||||
t.Helper()
|
||||
updateDate := time.UnixMilli(updateTime)
|
||||
if err := dao.NewAPI4ConversationDAO().Create(t.Context(), dao.DB, &entity.API4Conversation{
|
||||
ID: id,
|
||||
DialogID: agentID,
|
||||
UserID: userID,
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello","prompt":"hidden"}]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: id,
|
||||
DialogID: agentID,
|
||||
UserID: userID,
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello","prompt":"hidden"}]`),
|
||||
BaseModel: entity.BaseModel{
|
||||
CreateTime: ptr(updateTime),
|
||||
CreateDate: &updateDate,
|
||||
@@ -1061,7 +1060,15 @@ func TestGetAgentSessionServiceSuccess(t *testing.T) {
|
||||
createAgentSessionTestConversation(t, "session-1", "canvas-1", "user-1", 1000)
|
||||
|
||||
ctx := serviceTestContext(t, "user-1", "user-1")
|
||||
session, code, err := NewAgentService().GetAgentSession(ctx, "user-1", "canvas-1", "session-1")
|
||||
if err := dao.NewAPI4ConversationDAO().UpdateHistory(ctx, dao.DB, "session-1", "canvas-1", "user-1", nil, dao.ConversationHistoryUpdate{
|
||||
Message: map[string]interface{}{"role": "assistant", "content": "answer", "reference": map[string]interface{}{
|
||||
"chunks": []interface{}{map[string]interface{}{"chunk_id": "chunk", "doc_id": "document", "content_with_weight": "evidence"}},
|
||||
"doc_aggs": []interface{}{},
|
||||
}},
|
||||
}); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
session, code, err := NewAgentService().GetAgentSession(ctx, "user-1", "canvas-1", "session-1", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil {
|
||||
t.Fatalf("GetAgentSession failed: %v", err)
|
||||
}
|
||||
@@ -1077,6 +1084,27 @@ func TestGetAgentSessionServiceSuccess(t *testing.T) {
|
||||
if session.DialogID != "canvas-1" {
|
||||
t.Fatalf("expected dialog_id canvas-1, got %s", session.DialogID)
|
||||
}
|
||||
messages := parseMessages(session.Message)
|
||||
reference, ok := messages[1]["reference"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("answer reference is not an object: %v", messages[1])
|
||||
}
|
||||
chunks := reference["chunks"].([]interface{})
|
||||
if len(chunks) != 1 || chunks[0].(map[string]interface{})["document_id"] != "document" || chunks[0].(map[string]interface{})["id"] != "chunk" {
|
||||
t.Fatalf("unexpected reference chunks: %v", chunks)
|
||||
}
|
||||
body, err := json.Marshal(session)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var payload map[string]interface{}
|
||||
if err := json.Unmarshal(body, &payload); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := payload["reference"]; ok {
|
||||
t.Fatalf("session contains reference array: %s", body)
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// TestCreateAgentSessionLeavesVersionTitleNilWithVersions pins the
|
||||
|
||||
@@ -604,7 +604,14 @@ func buildChatbotPipelineMessages(raw json.RawMessage, question, messageID strin
|
||||
if role == "assistant" && len(msg) == 0 {
|
||||
continue
|
||||
}
|
||||
msg = append(msg, m)
|
||||
message := make(map[string]interface{}, len(m))
|
||||
for key, value := range m {
|
||||
if key == "prompt" || key == "usage" || key == "elapsed_ms" || key == "reference" {
|
||||
continue
|
||||
}
|
||||
message[key] = value
|
||||
}
|
||||
msg = append(msg, message)
|
||||
}
|
||||
return msg
|
||||
}
|
||||
@@ -638,7 +645,8 @@ func (s *BotService) persistChatbotTurn(
|
||||
reference = map[string]any{"chunks": []any{}, "doc_aggs": []any{}}
|
||||
}
|
||||
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})
|
||||
message["reference"] = reference
|
||||
return s.api4ConversationDAO.UpdateHistory(ctx, dao.DB, session.ID, session.DialogID, session.UserID, nil, dao.ConversationHistoryUpdate{Message: message, QuestionID: messageID})
|
||||
}
|
||||
|
||||
// normalizeBotBoolFlag coerces the JSON-encoded reasoning / internet
|
||||
|
||||
@@ -66,7 +66,7 @@ func TestBuildChatbotPipelineMessages_DropsLeadingAssistantAndSystem(t *testing.
|
||||
{"role": "assistant", "content": "Hello, how can I help?", "created_at": 1},
|
||||
{"role": "system", "content": "hidden", "created_at": 2},
|
||||
{"role": "user", "content": "What is Go?", "created_at": 3},
|
||||
{"role": "assistant", "content": "Go is a compiled language.", "created_at": 4},
|
||||
{"role": "assistant", "content": "Go is a compiled language.", "created_at": 4, "reference": map[string]any{"chunks": []any{"large chunk"}}, "prompt": "system", "usage": map[string]any{"total_tokens": 10}, "elapsed_ms": 100},
|
||||
}
|
||||
raw, err := json.Marshal(turns)
|
||||
if err != nil {
|
||||
@@ -82,6 +82,11 @@ func TestBuildChatbotPipelineMessages_DropsLeadingAssistantAndSystem(t *testing.
|
||||
if msgs[1]["role"] != "assistant" || msgs[1]["content"] != "Go is a compiled language." {
|
||||
t.Errorf("turn 1: %+v", msgs[1])
|
||||
}
|
||||
for _, key := range []string{"prompt", "usage", "elapsed_ms", "reference"} {
|
||||
if _, ok := msgs[1][key]; ok {
|
||||
t.Fatalf("LLM history contains %s: %v", key, msgs[1])
|
||||
}
|
||||
}
|
||||
if msgs[2]["role"] != "user" || msgs[2]["content"] != "and Rust?" || msgs[2]["id"] != "msg-2" {
|
||||
t.Errorf("turn 2: %+v", msgs[2])
|
||||
}
|
||||
@@ -518,7 +523,7 @@ func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
|
||||
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")
|
||||
questionRow, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p1", "dlg-p1", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil || questionRow == nil {
|
||||
t.Fatalf("read pending question: %v", err)
|
||||
}
|
||||
@@ -529,7 +534,7 @@ func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
|
||||
t.Fatalf("persistChatbotTurn: %v", err)
|
||||
}
|
||||
|
||||
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p1", "dlg-p1")
|
||||
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p1", "dlg-p1", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("re-read session: row=%v err=%v", row, err)
|
||||
}
|
||||
@@ -550,8 +555,10 @@ func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
|
||||
}
|
||||
|
||||
var refs []map[string]any
|
||||
if err := json.Unmarshal(row.Reference, &refs); err != nil {
|
||||
t.Fatalf("reference decode: %v", err)
|
||||
for _, message := range parseChatbotTurns(row.Message) {
|
||||
if reference, ok := message["reference"].(map[string]any); ok {
|
||||
refs = append(refs, reference)
|
||||
}
|
||||
}
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("want 1 reference entry, got %d: %+v", len(refs), refs)
|
||||
@@ -564,7 +571,7 @@ func TestPersistChatbotTurn_AppendsPairAndReference(t *testing.T) {
|
||||
|
||||
// TestPersistChatbotTurn_NilReferenceDefaultsToEmpty ensures a turn
|
||||
// without retrieval results still records an empty reference object,
|
||||
// keeping the reference list index-aligned with the turn pairs.
|
||||
// attached directly to its assistant message.
|
||||
func TestPersistChatbotTurn_NilReferenceDefaultsToEmpty(t *testing.T) {
|
||||
db := setupServiceTestDB(t)
|
||||
pushServiceDB(t, db)
|
||||
@@ -583,13 +590,15 @@ func TestPersistChatbotTurn_NilReferenceDefaultsToEmpty(t *testing.T) {
|
||||
t.Fatalf("persistChatbotTurn: %v", err)
|
||||
}
|
||||
|
||||
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p2", "dlg-p1")
|
||||
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p2", "dlg-p1", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("re-read session: row=%v err=%v", row, err)
|
||||
}
|
||||
var refs []map[string]any
|
||||
if err := json.Unmarshal(row.Reference, &refs); err != nil {
|
||||
t.Fatalf("reference decode: %v", err)
|
||||
for _, message := range parseChatbotTurns(row.Message) {
|
||||
if reference, ok := message["reference"].(map[string]any); ok {
|
||||
refs = append(refs, reference)
|
||||
}
|
||||
}
|
||||
if len(refs) != 1 {
|
||||
t.Fatalf("want 1 reference entry, got %+v", refs)
|
||||
@@ -643,7 +652,7 @@ func TestPersistChatbotTurn_ConcurrentSameSession(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
|
||||
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p3", "dlg-p1")
|
||||
row, err := dao.NewAPI4ConversationDAO().GetBySessionID(ctx, dao.DB, "sess-p3", "dlg-p1", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil || row == nil {
|
||||
t.Fatalf("re-read session: row=%v err=%v", row, err)
|
||||
}
|
||||
@@ -651,8 +660,10 @@ func TestPersistChatbotTurn_ConcurrentSameSession(t *testing.T) {
|
||||
t.Fatalf("want both exchanges persisted (4 turns), got %d: %+v", len(turns), turns)
|
||||
}
|
||||
var refs []map[string]any
|
||||
if err = json.Unmarshal(row.Reference, &refs); err != nil {
|
||||
t.Fatalf("reference decode: %v", err)
|
||||
for _, message := range parseChatbotTurns(row.Message) {
|
||||
if reference, ok := message["reference"].(map[string]any); ok {
|
||||
refs = append(refs, reference)
|
||||
}
|
||||
}
|
||||
if len(refs) != 2 {
|
||||
t.Fatalf("want 2 reference entries, got %d: %+v", len(refs), refs)
|
||||
|
||||
@@ -44,12 +44,12 @@ import (
|
||||
// Interfaces for testability — satisfied by the concrete DAO/pipeline types.
|
||||
|
||||
type chatSessionStore interface {
|
||||
GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.ChatSession, error)
|
||||
GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string) (*entity.ChatSession, error)
|
||||
GetByID(ctx context.Context, db *gorm.DB, id string, options ...dao.ConversationHistoryOptions) (*entity.ChatSession, error)
|
||||
GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string, options ...dao.ConversationHistoryOptions) (*entity.ChatSession, error)
|
||||
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
|
||||
ListByResourceIDs(ctx context.Context, db *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, includeHistory ...bool) ([]*entity.ChatSession, int64, error)
|
||||
ListByResourceIDs(ctx context.Context, db *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, options ...dao.ConversationHistoryOptions) ([]*entity.ChatSession, int64, error)
|
||||
GetDialogByID(ctx context.Context, db *gorm.DB, chatID string) (*entity.Chat, error)
|
||||
}
|
||||
|
||||
@@ -121,7 +121,6 @@ type ChatSessionPayload struct {
|
||||
ChatID string `json:"chat_id"`
|
||||
Name *string `json:"name,omitempty"`
|
||||
Messages []map[string]interface{} `json:"messages"`
|
||||
Reference []interface{} `json:"reference"`
|
||||
UserID *string `json:"user_id,omitempty"`
|
||||
Avatar *string `json:"avatar,omitempty"`
|
||||
CreateDate *time.Time `json:"create_date,omitempty"`
|
||||
@@ -131,14 +130,14 @@ type ChatSessionPayload struct {
|
||||
}
|
||||
|
||||
// ListChatSessions lists chat sessions for a dialog
|
||||
func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatID, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, includeHistory ...bool) (*ListChatSessionsResponse, error) {
|
||||
func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatID, sessionID, name string, terms []dao.OrderTerm, page, pageSize int, options ...dao.ConversationHistoryOptions) (*ListChatSessionsResponse, error) {
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
_, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, normalizedErr
|
||||
}
|
||||
|
||||
// List chat sessions
|
||||
sessions, total, err := s.chatSessionDAO.ListByResourceIDs(ctx, dao.DB, chatID, nil, sessionID, name, terms, page, pageSize, includeHistory...)
|
||||
sessions, total, err := s.chatSessionDAO.ListByResourceIDs(ctx, dao.DB, chatID, nil, sessionID, name, terms, page, pageSize, sessionHistoryOptions(options...))
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -174,13 +173,13 @@ func (s *ChatSessionService) ListChatSessions(ctx context.Context, userID, chatI
|
||||
}
|
||||
|
||||
// GetSession returns one chat session after ownership validation.
|
||||
func (s *ChatSessionService) GetSession(ctx context.Context, userID, chatID, sessionID string) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
func (s *ChatSessionService) GetSession(ctx context.Context, userID, chatID, sessionID string, options ...dao.ConversationHistoryOptions) (*ChatSessionPayload, common.ErrorCode, error) {
|
||||
if err := checkChatPermission(ctx, s.permissionChecker, userID, chatID, permission.OperationRead); err != nil {
|
||||
code, normalizedErr := normalizeChatPermissionError(err)
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID, sessionHistoryOptions(options...))
|
||||
if err != nil {
|
||||
if isChatSessionNotFound(err) {
|
||||
return nil, common.CodeDataError, errors.New("session not found")
|
||||
@@ -200,7 +199,7 @@ func (s *ChatSessionService) GetSession(ctx context.Context, userID, chatID, ses
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
|
||||
return s.buildSessionPayload(session, dialog, true), common.CodeSuccess, nil
|
||||
return s.buildSessionPayload(session, dialog, true, options...), common.CodeSuccess, nil
|
||||
}
|
||||
|
||||
// CreateSession create a session in a dialog
|
||||
@@ -245,22 +244,19 @@ func (s *ChatSessionService) CreateSession(ctx context.Context, userID, chatID s
|
||||
},
|
||||
})
|
||||
|
||||
referenceJSON, _ := json.Marshal([]interface{}{})
|
||||
|
||||
conv := &entity.ChatSession{
|
||||
ID: common.GenerateUUID(),
|
||||
DialogID: chatID,
|
||||
Name: &name,
|
||||
Message: messagesJSON,
|
||||
UserID: &userID,
|
||||
Reference: referenceJSON,
|
||||
ID: common.GenerateUUID(),
|
||||
DialogID: chatID,
|
||||
Name: &name,
|
||||
Message: messagesJSON,
|
||||
UserID: &userID,
|
||||
}
|
||||
|
||||
if err = s.chatSessionDAO.Create(ctx, dao.DB, conv); err != nil {
|
||||
return nil, common.CodeDataError, errors.New("fail to create a session")
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, conv.ID)
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, conv.ID, sessionHistoryOptions())
|
||||
if err != nil {
|
||||
return nil, common.CodeDataError, errors.New("fail to create a session")
|
||||
}
|
||||
@@ -291,7 +287,7 @@ func (s *ChatSessionService) DeleteSessions(ctx context.Context, userID, chatID
|
||||
if len(sessionIDs) == 0 {
|
||||
return map[string]interface{}{}, "success", common.CodeSuccess, nil
|
||||
}
|
||||
sessions, _, err := s.chatSessionDAO.ListByResourceIDs(ctx, dao.DB, chatID, sessionIDs, "", "", []dao.OrderTerm{{Column: "create_time", Desc: true}}, 0, -1, false)
|
||||
sessions, _, err := s.chatSessionDAO.ListByResourceIDs(ctx, dao.DB, chatID, sessionIDs, "", "", []dao.OrderTerm{{Column: "create_time", Desc: true}}, 0, -1, dao.ConversationHistoryOptions{NoHistory: true})
|
||||
if err != nil {
|
||||
return nil, "", common.CodeServerError, err
|
||||
}
|
||||
@@ -500,7 +496,7 @@ func (s *ChatSessionService) UpdateSession(ctx context.Context, userID, chatID,
|
||||
}
|
||||
}
|
||||
|
||||
session, err = s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
session, err = s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID, sessionHistoryOptions())
|
||||
if err != nil {
|
||||
if isChatSessionNotFound(err) {
|
||||
return nil, common.CodeDataError, errors.New("fail to update a session")
|
||||
@@ -517,7 +513,7 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID, sessionHistoryOptions())
|
||||
if err != nil || session.DialogID != chatID {
|
||||
if err != nil && !isChatSessionNotFound(err) {
|
||||
return nil, common.CodeServerError, err
|
||||
@@ -530,17 +526,10 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
// parseMessages / parseReferenceList return nil for
|
||||
// malformed input, so the existing `nil` guards below are
|
||||
// the single source of truth for "this blob is corrupt".
|
||||
messages := parseMessages(session.Message)
|
||||
if len(session.Message) > 0 && messages == nil {
|
||||
return nil, common.CodeDataError, errors.New("invalid session messages")
|
||||
}
|
||||
references := parseReferenceList(session.Reference)
|
||||
if len(session.Reference) > 0 && references == nil {
|
||||
return nil, common.CodeDataError, errors.New("invalid session reference")
|
||||
}
|
||||
found := false
|
||||
for i, msg := range messages {
|
||||
if msgID != stringValue(msg["id"]) {
|
||||
@@ -551,10 +540,6 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
}
|
||||
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:]...)
|
||||
@@ -569,10 +554,6 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
referenceRaw, err := json.Marshal(references)
|
||||
if err != nil {
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
if err = s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, map[string]interface{}{
|
||||
"history_update": dao.ConversationHistoryUpdate{DeleteMessageID: msgID},
|
||||
}); err != nil {
|
||||
@@ -582,7 +563,6 @@ func (s *ChatSessionService) DeleteSessionMessage(ctx context.Context, userID, c
|
||||
return nil, common.CodeServerError, err
|
||||
}
|
||||
session.Message = messageRaw
|
||||
session.Reference = referenceRaw
|
||||
|
||||
return s.buildSessionPayload(session, nil, false), common.CodeSuccess, nil
|
||||
}
|
||||
@@ -593,7 +573,9 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
|
||||
return nil, code, normalizedErr
|
||||
}
|
||||
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
historyOptions := sessionHistoryOptions()
|
||||
historyOptions.IncludeReference = true
|
||||
session, err := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID, historyOptions)
|
||||
if err != nil || session.DialogID != chatID {
|
||||
if err != nil && !isChatSessionNotFound(err) {
|
||||
return nil, common.CodeServerError, err
|
||||
@@ -623,13 +605,6 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
|
||||
if len(session.Message) > 0 && messages == nil {
|
||||
return nil, common.CodeDataError, errors.New("invalid session messages")
|
||||
}
|
||||
// References are only used later in this function but a
|
||||
// malformed blob must surface immediately, not silently
|
||||
// collapse to an empty slice.
|
||||
references := parseReferenceList(session.Reference)
|
||||
if len(session.Reference) > 0 && references == nil {
|
||||
return nil, common.CodeDataError, errors.New("invalid session reference")
|
||||
}
|
||||
messageIndex := -1
|
||||
var priorThumb interface{}
|
||||
applyChunkFeedback := false
|
||||
@@ -657,12 +632,7 @@ func (s *ChatSessionService) UpdateMessageFeedback(ctx context.Context, userID,
|
||||
}
|
||||
|
||||
if messageIndex != -1 && applyChunkFeedback {
|
||||
refIndex := sessionMessageReferenceIndex(messages, messageIndex)
|
||||
if refIndex >= 0 && refIndex < len(references) {
|
||||
if reference, ok := references[refIndex].(map[string]interface{}); ok && len(reference) > 0 {
|
||||
feedbackReference = reference
|
||||
}
|
||||
}
|
||||
feedbackReference, _ = messages[messageIndex]["reference"].(map[string]interface{})
|
||||
}
|
||||
|
||||
messageRaw, err := json.Marshal(messages)
|
||||
@@ -706,28 +676,6 @@ 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
|
||||
@@ -1028,7 +976,17 @@ func floatValue(value interface{}) (float64, bool) {
|
||||
}
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) buildSessionPayload(session *entity.ChatSession, dialog *entity.Chat, includeAvatar bool) *ChatSessionPayload {
|
||||
func sessionHistoryOptions(options ...dao.ConversationHistoryOptions) dao.ConversationHistoryOptions {
|
||||
var result dao.ConversationHistoryOptions
|
||||
if len(options) > 0 {
|
||||
result = options[0]
|
||||
}
|
||||
result.IncludeUsage = true
|
||||
result.IncludeElapsedMs = true
|
||||
return result
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) buildSessionPayload(session *entity.ChatSession, dialog *entity.Chat, includeAvatar bool, options ...dao.ConversationHistoryOptions) *ChatSessionPayload {
|
||||
var avatar *string
|
||||
if includeAvatar {
|
||||
value := ""
|
||||
@@ -1038,22 +996,24 @@ func (s *ChatSessionService) buildSessionPayload(session *entity.ChatSession, di
|
||||
avatar = &value
|
||||
}
|
||||
|
||||
references := parseReferenceList(session.Reference)
|
||||
for index, ref := range references {
|
||||
refMap, ok := ref.(map[string]interface{})
|
||||
if !ok {
|
||||
continue
|
||||
historyOptions := sessionHistoryOptions(options...)
|
||||
messages := parseMessages(session.Message)
|
||||
for _, message := range messages {
|
||||
if !historyOptions.IncludePrompt {
|
||||
delete(message, "prompt")
|
||||
}
|
||||
if !historyOptions.IncludeReference {
|
||||
delete(message, "reference")
|
||||
} else if reference, ok := message["reference"].(map[string]interface{}); ok {
|
||||
reference["chunks"] = formatReferenceChunks(reference)
|
||||
}
|
||||
refMap["chunks"] = formatReferenceChunks(refMap)
|
||||
references[index] = refMap
|
||||
}
|
||||
|
||||
return &ChatSessionPayload{
|
||||
ID: session.ID,
|
||||
ChatID: session.DialogID,
|
||||
Name: session.Name,
|
||||
Messages: parseMessages(session.Message),
|
||||
Reference: references,
|
||||
Messages: messages,
|
||||
UserID: session.UserID,
|
||||
Avatar: avatar,
|
||||
CreateDate: session.CreateDate,
|
||||
@@ -1118,23 +1078,6 @@ func parseMessages(raw json.RawMessage) []map[string]interface{} {
|
||||
return messages
|
||||
}
|
||||
|
||||
// parseReferenceList decodes a session.Reference blob. Same
|
||||
// nil-on-malformed contract as parseMessages — callers gate on
|
||||
// `if len(raw) > 0 && references == nil` to reject corruption.
|
||||
func parseReferenceList(raw json.RawMessage) []interface{} {
|
||||
references := make([]interface{}, 0)
|
||||
if len(raw) == 0 {
|
||||
return references
|
||||
}
|
||||
if err := json.Unmarshal(raw, &references); err != nil {
|
||||
return nil
|
||||
}
|
||||
if references == nil {
|
||||
return make([]interface{}, 0)
|
||||
}
|
||||
return references
|
||||
}
|
||||
|
||||
func formatReferenceChunks(reference map[string]interface{}) []FormattedChunk {
|
||||
rawChunks, ok := reference["chunks"].([]interface{})
|
||||
if !ok {
|
||||
@@ -1262,9 +1205,9 @@ func (s *ChatSessionService) ChatCompletions(
|
||||
// --- 4. Initialize reference ---
|
||||
var reference []interface{}
|
||||
if session != nil {
|
||||
reference = s.initializeReference(session)
|
||||
reference = s.initializeReference()
|
||||
} else if !storeHistoryMessages {
|
||||
reference = s.initializeReference(&entity.ChatSession{})
|
||||
reference = s.initializeReference()
|
||||
}
|
||||
|
||||
// --- 5. LLM override ---
|
||||
@@ -1602,7 +1545,7 @@ func (s *ChatSessionService) normalizeCompletionMessages(
|
||||
for _, msg := range messages {
|
||||
message := make(map[string]interface{}, len(msg))
|
||||
for key, value := range msg {
|
||||
if key == "prompt" || key == "usage" || key == "elapsed_ms" {
|
||||
if key == "prompt" || key == "usage" || key == "elapsed_ms" || key == "reference" {
|
||||
continue
|
||||
}
|
||||
message[key] = value
|
||||
@@ -1649,14 +1592,12 @@ func (s *ChatSessionService) createSessionForCompletion(ctx context.Context, cha
|
||||
{"role": "assistant", "content": prologue, "created_at": float64(time.Now().Unix())},
|
||||
}
|
||||
msgJSON, _ := json.Marshal(msg)
|
||||
refJSON, _ := json.Marshal([]interface{}{})
|
||||
|
||||
session := &entity.ChatSession{
|
||||
DialogID: chatID,
|
||||
Name: &name,
|
||||
Message: msgJSON,
|
||||
UserID: &userID,
|
||||
Reference: refJSON,
|
||||
DialogID: chatID,
|
||||
Name: &name,
|
||||
Message: msgJSON,
|
||||
UserID: &userID,
|
||||
}
|
||||
session.ID = common.GenerateUUID()
|
||||
if err := s.chatSessionDAO.Create(ctx, dao.DB, session); err != nil {
|
||||
@@ -1679,17 +1620,15 @@ func (s *ChatSessionService) GetOrCreateForChannel(ctx context.Context, dialogID
|
||||
}
|
||||
|
||||
messagesJSON, _ := json.Marshal([]map[string]interface{}{})
|
||||
referenceJSON, _ := json.Marshal([]interface{}{})
|
||||
name := channelSessionName(firstQuery, channelName)
|
||||
if name == "" {
|
||||
name = fmt.Sprintf("channel:%s:%s", channelID, chatID)
|
||||
}
|
||||
session = &entity.ChatSession{
|
||||
ID: sessionID,
|
||||
DialogID: dialogID,
|
||||
Name: &name,
|
||||
Message: messagesJSON,
|
||||
Reference: referenceJSON,
|
||||
ID: sessionID,
|
||||
DialogID: dialogID,
|
||||
Name: &name,
|
||||
Message: messagesJSON,
|
||||
}
|
||||
if err = s.chatSessionDAO.Create(ctx, dao.DB, session); err != nil {
|
||||
session, rereadErr := s.chatSessionDAO.GetByID(ctx, dao.DB, sessionID)
|
||||
@@ -1775,7 +1714,7 @@ func (s *ChatSessionService) filterSystemAndLeadingAssistant(session *entity.Cha
|
||||
}
|
||||
message := make(map[string]interface{}, len(msg))
|
||||
for key, value := range msg {
|
||||
if key == "prompt" || key == "usage" || key == "elapsed_ms" {
|
||||
if key == "prompt" || key == "usage" || key == "elapsed_ms" || key == "reference" {
|
||||
continue
|
||||
}
|
||||
message[key] = value
|
||||
@@ -1838,23 +1777,11 @@ func (s *ChatSessionService) sendSSEError(streamChan chan<- string, errMsg strin
|
||||
|
||||
// Helper methods
|
||||
|
||||
func (s *ChatSessionService) initializeReference(session *entity.ChatSession) []interface{} {
|
||||
var reference []interface{}
|
||||
if len(session.Reference) > 0 {
|
||||
json.Unmarshal(session.Reference, &reference)
|
||||
}
|
||||
// Filter out nil entries and append new reference
|
||||
var filtered []interface{}
|
||||
for _, r := range reference {
|
||||
if r != nil {
|
||||
filtered = append(filtered, r)
|
||||
}
|
||||
}
|
||||
filtered = append(filtered, map[string]interface{}{
|
||||
func (s *ChatSessionService) initializeReference() []interface{} {
|
||||
return []interface{}{map[string]interface{}{
|
||||
"chunks": []map[string]interface{}{},
|
||||
"doc_aggs": []interface{}{},
|
||||
})
|
||||
return filtered
|
||||
}}
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) checkTenantLLMAPIKey(ctx context.Context, tenantID, modelName string) (bool, error) {
|
||||
@@ -2092,32 +2019,24 @@ func (s *ChatSessionService) structureAnswer(session *entity.ChatSession, answer
|
||||
}
|
||||
|
||||
func (s *ChatSessionService) updateSessionMessages(ctx context.Context, session *entity.ChatSession, messages []map[string]interface{}, reference []interface{}) {
|
||||
if len(messages) == 0 || stringValue(messages[len(messages)-1]["role"]) != "assistant" {
|
||||
return
|
||||
}
|
||||
message := messages[len(messages)-1]
|
||||
if len(reference) > 0 {
|
||||
message["reference"] = reference[len(reference)-1]
|
||||
}
|
||||
messagesJSON, err := json.Marshal(messages)
|
||||
if err != nil {
|
||||
common.Warn("updateSessionMessages: failed to marshal messages", zap.Error(err))
|
||||
return
|
||||
}
|
||||
referenceJSON, err := json.Marshal(reference)
|
||||
if err != nil {
|
||||
common.Warn("updateSessionMessages: failed to marshal reference", zap.Error(err))
|
||||
return
|
||||
}
|
||||
|
||||
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}}
|
||||
updates := map[string]interface{}{"history_update": dao.ConversationHistoryUpdate{Message: message, QuestionID: stringValue(message["id"])}}
|
||||
if err := s.chatSessionDAO.UpdateByID(ctx, dao.DB, session.ID, updates); err != nil {
|
||||
common.Warn("updateSessionMessages: DAO update failed", zap.Error(err))
|
||||
return
|
||||
}
|
||||
session.Message = messagesJSON
|
||||
session.Reference = referenceJSON
|
||||
}
|
||||
|
||||
// structureAnswerWithConv structures the answer with conversation update (like Python's structure_answer)
|
||||
|
||||
@@ -52,7 +52,7 @@ func newFakeSessionStore() *fakeSessionStore {
|
||||
}
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) GetByID(ctx context.Context, db *gorm.DB, id string) (*entity.ChatSession, error) {
|
||||
func (f *fakeSessionStore) GetByID(ctx context.Context, db *gorm.DB, id string, options ...dao.ConversationHistoryOptions) (*entity.ChatSession, error) {
|
||||
if f.getByIDErr != nil {
|
||||
return nil, f.getByIDErr
|
||||
}
|
||||
@@ -64,7 +64,7 @@ func (f *fakeSessionStore) GetByID(ctx context.Context, db *gorm.DB, id string)
|
||||
return &clone, nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string) (*entity.ChatSession, error) {
|
||||
func (f *fakeSessionStore) GetBySessionIDAndChatID(ctx context.Context, db *gorm.DB, sessionID, chatID string, options ...dao.ConversationHistoryOptions) (*entity.ChatSession, error) {
|
||||
s, err := f.GetByID(ctx, db, sessionID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
@@ -99,7 +99,6 @@ func (f *fakeSessionStore) UpdateByID(ctx context.Context, db *gorm.DB, id strin
|
||||
}
|
||||
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 {
|
||||
@@ -107,10 +106,6 @@ func (f *fakeSessionStore) UpdateByID(ctx context.Context, db *gorm.DB, id strin
|
||||
}
|
||||
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:]...)
|
||||
@@ -131,12 +126,8 @@ func (f *fakeSessionStore) UpdateByID(ctx context.Context, db *gorm.DB, id strin
|
||||
}
|
||||
} 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 {
|
||||
@@ -153,10 +144,6 @@ func (f *fakeSessionStore) UpdateByID(ctx context.Context, db *gorm.DB, id strin
|
||||
if raw, ok := v.([]byte); ok {
|
||||
s.Message = append(json.RawMessage(nil), raw...)
|
||||
}
|
||||
case "reference":
|
||||
if raw, ok := v.([]byte); ok {
|
||||
s.Reference = append(json.RawMessage(nil), raw...)
|
||||
}
|
||||
}
|
||||
}
|
||||
return nil
|
||||
@@ -167,7 +154,7 @@ func (f *fakeSessionStore) DeleteByID(ctx context.Context, db *gorm.DB, id strin
|
||||
return nil
|
||||
}
|
||||
|
||||
func (f *fakeSessionStore) ListByResourceIDs(_ context.Context, _ *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, _ []dao.OrderTerm, _, pageSize int, _ ...bool) ([]*entity.ChatSession, int64, error) {
|
||||
func (f *fakeSessionStore) ListByResourceIDs(_ context.Context, _ *gorm.DB, chatID string, sessionIDs []string, sessionID, name string, _ []dao.OrderTerm, _, pageSize int, _ ...dao.ConversationHistoryOptions) ([]*entity.ChatSession, int64, error) {
|
||||
allowedIDs := make(map[string]struct{}, len(sessionIDs))
|
||||
for _, id := range sessionIDs {
|
||||
allowedIDs[id] = struct{}{}
|
||||
@@ -358,11 +345,11 @@ func TestChatCompletionsPersistsAnswerDetailsForHistory(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.sessions["session-1"] = &entity.ChatSession{
|
||||
ID: "session-1", DialogID: "chat-1", UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome"},{"role":"user","content":"old question","id":"old"},{"role":"assistant","content":"old answer","id":"old","prompt":"old prompt","usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3},"elapsed_ms":123}]`),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome"},{"role":"user","content":"old question","id":"old"},{"role":"assistant","content":"old answer","id":"old","prompt":"old prompt","usage":{"prompt_tokens":1,"completion_tokens":2,"total_tokens":3},"elapsed_ms":123,"reference":{"chunks":[{"id":"old-chunk"}]}}]`),
|
||||
}
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", TenantID: "user-1", LLMID: "chat@factory"}
|
||||
store.dialogExists["user-1|chat-1"] = true
|
||||
final := AsyncChatResult{Answer: "new answer", Prompt: "system prompt", Final: true}
|
||||
final := AsyncChatResult{Answer: "new answer", Prompt: "system prompt", Reference: map[string]interface{}{"chunks": []interface{}{map[string]interface{}{"id": "new-chunk"}}}, Final: true}
|
||||
if mode.agentic {
|
||||
final.Answer = "<think>agent reasoning</think>new answer"
|
||||
final.Prompt = ""
|
||||
@@ -384,13 +371,13 @@ func TestChatCompletionsPersistsAnswerDetailsForHistory(t *testing.T) {
|
||||
t.Fatalf("unexpected LLM history: %v", pipeline.messages)
|
||||
}
|
||||
for _, message := range pipeline.messages {
|
||||
for _, key := range []string{"prompt", "usage", "elapsed_ms"} {
|
||||
for _, key := range []string{"prompt", "usage", "elapsed_ms", "reference"} {
|
||||
if _, ok := message[key]; ok {
|
||||
t.Fatalf("LLM history contains %s: %v", key, message)
|
||||
}
|
||||
}
|
||||
}
|
||||
payload, code, err := svc.GetSession(t.Context(), "user-1", "chat-1", "session-1")
|
||||
payload, code, err := svc.GetSession(t.Context(), "user-1", "chat-1", "session-1", dao.ConversationHistoryOptions{IncludeReference: true, IncludePrompt: true})
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("GetSession: code=%v err=%v", code, err)
|
||||
}
|
||||
@@ -401,6 +388,21 @@ func TestChatCompletionsPersistsAnswerDetailsForHistory(t *testing.T) {
|
||||
if answer["content"] != final.Answer {
|
||||
t.Fatalf("missing stored answer details: %v", answer)
|
||||
}
|
||||
ref, ok := answer["reference"].(map[string]interface{})
|
||||
if !ok || len(ref["chunks"].([]FormattedChunk)) != 1 || ref["chunks"].([]FormattedChunk)[0].ID != "new-chunk" {
|
||||
t.Fatalf("missing stored reference: %v", answer)
|
||||
}
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var data map[string]interface{}
|
||||
if err := json.Unmarshal(body, &data); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, ok := data["reference"]; ok {
|
||||
t.Fatalf("session contains a reference array: %s", body)
|
||||
}
|
||||
if mode.agentic {
|
||||
if _, ok := answer["prompt"]; ok {
|
||||
t.Fatalf("agentic answer stored a prompt: %v", answer)
|
||||
@@ -427,7 +429,7 @@ func TestChatCompletionsPersistsAnswerDetailsForHistory(t *testing.T) {
|
||||
|
||||
func TestNormalizeCompletionMessagesExcludesAnswerDetails(t *testing.T) {
|
||||
messages := []map[string]interface{}{
|
||||
{"role": "assistant", "content": "answer", "prompt": "system prompt", "usage": map[string]int{"total_tokens": 3}, "elapsed_ms": 123.0},
|
||||
{"role": "assistant", "content": "answer", "prompt": "system prompt", "usage": map[string]int{"total_tokens": 3}, "elapsed_ms": 123.0, "reference": map[string]interface{}{"chunks": []interface{}{}}},
|
||||
{"role": "user", "content": "question", "id": "turn-1"},
|
||||
}
|
||||
svc := &ChatSessionService{}
|
||||
@@ -583,7 +585,7 @@ func TestListChatSessions_Success(t *testing.T) {
|
||||
|
||||
ctx := t.Context()
|
||||
terms := []dao.OrderTerm{{Column: "create_time", Desc: true}}
|
||||
resp, err := svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", terms, 1, 30, false)
|
||||
resp, err := svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", terms, 1, 30, dao.ConversationHistoryOptions{NoHistory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -596,7 +598,7 @@ func TestListChatSessions_Success(t *testing.T) {
|
||||
}
|
||||
}
|
||||
for page, wantCount := range map[int]int{4: 10, 5: 1, 6: 0} {
|
||||
resp, err := svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", terms, page, 10, false)
|
||||
resp, err := svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", terms, page, 10, dao.ConversationHistoryOptions{NoHistory: true})
|
||||
if err != nil {
|
||||
t.Fatalf("page %d: %v", page, err)
|
||||
}
|
||||
@@ -604,11 +606,11 @@ func TestListChatSessions_Success(t *testing.T) {
|
||||
t.Fatalf("page %d: got total=%d and %d sessions, want total=41 and %d sessions", page, resp.Total, len(resp.Sessions), wantCount)
|
||||
}
|
||||
}
|
||||
resp, err = svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", terms, 1, 0, false)
|
||||
resp, err = svc.ListChatSessions(ctx, "user-1", "chat-1", "", "", terms, 1, 0, dao.ConversationHistoryOptions{NoHistory: true})
|
||||
if err != nil || resp.Total != 41 || len(resp.Sessions) != 0 {
|
||||
t.Fatalf("zero page size: got response=%+v err=%v, want total=41 and no sessions", resp, err)
|
||||
}
|
||||
resp, err = svc.ListChatSessions(ctx, "user-1", "chat-1", "session-01", "", terms, 1, 30, false)
|
||||
resp, err = svc.ListChatSessions(ctx, "user-1", "chat-1", "session-01", "", terms, 1, 30, dao.ConversationHistoryOptions{NoHistory: true})
|
||||
if err != nil || resp.Total != 1 || len(resp.Sessions) != 1 {
|
||||
t.Fatalf("ID filter: got response=%+v err=%v, want total=1 and one session", resp, err)
|
||||
}
|
||||
@@ -640,12 +642,8 @@ func TestGetSession_Success(t *testing.T) {
|
||||
ID: "session-1",
|
||||
DialogID: "chat-1",
|
||||
Name: strPtr("session"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello"}]`),
|
||||
Reference: json.RawMessage(`[
|
||||
{"chunks":[{"chunk_id":"chunk-1","content_with_weight":"hello","doc_id":"doc-1","docnm_kwd":"Doc 1","kb_id":"kb-1"}]},
|
||||
[]
|
||||
]`),
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"hello","reference":{"chunks":[{"chunk_id":"chunk-1","content_with_weight":"hello","doc_id":"doc-1","docnm_kwd":"Doc 1","kb_id":"kb-1"}]}}]`),
|
||||
UserID: strPtr("user-1"),
|
||||
}
|
||||
icon := "avatar.png"
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", Icon: &icon}
|
||||
@@ -658,7 +656,7 @@ func TestGetSession_Success(t *testing.T) {
|
||||
}
|
||||
|
||||
ctx := t.Context()
|
||||
resp, code, err := svc.GetSession(ctx, "user-1", "chat-1", "session-1")
|
||||
resp, code, err := svc.GetSession(ctx, "user-1", "chat-1", "session-1", dao.ConversationHistoryOptions{IncludeReference: true, IncludePrompt: true})
|
||||
if err != nil {
|
||||
t.Fatalf("unexpected error: %v", err)
|
||||
}
|
||||
@@ -674,12 +672,9 @@ func TestGetSession_Success(t *testing.T) {
|
||||
if len(resp.Messages) != 1 || resp.Messages[0]["content"] != "hello" {
|
||||
t.Fatalf("messages=%#v", resp.Messages)
|
||||
}
|
||||
if len(resp.Reference) != 2 {
|
||||
t.Fatalf("reference len=%d", len(resp.Reference))
|
||||
}
|
||||
firstRef, ok := resp.Reference[0].(map[string]interface{})
|
||||
firstRef, ok := resp.Messages[0]["reference"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference[0] type=%T", resp.Reference[0])
|
||||
t.Fatalf("answer reference type=%T", resp.Messages[0]["reference"])
|
||||
}
|
||||
chunks, ok := firstRef["chunks"].([]FormattedChunk)
|
||||
if !ok {
|
||||
@@ -688,9 +683,6 @@ func TestGetSession_Success(t *testing.T) {
|
||||
if len(chunks) != 1 || chunks[0].ID != "chunk-1" {
|
||||
t.Fatalf("chunks=%#v", chunks)
|
||||
}
|
||||
if _, ok := resp.Reference[1].([]interface{}); !ok {
|
||||
t.Fatalf("reference[1] changed unexpectedly: %T", resp.Reference[1])
|
||||
}
|
||||
}
|
||||
|
||||
func TestGetSession_NotOwner(t *testing.T) {
|
||||
@@ -842,21 +834,12 @@ func TestUpdateSession_NotFound(t *testing.T) {
|
||||
|
||||
func TestDeleteSessionMessage_RemovesMessagePairAndReference(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1"}
|
||||
store.sessions["session-1"] = &entity.ChatSession{
|
||||
ID: "session-1",
|
||||
DialogID: "chat-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[
|
||||
{"role":"assistant","content":"Welcome!"},
|
||||
{"role":"user","content":"first","id":"msg-1"},
|
||||
{"role":"assistant","content":"answer 1","id":"msg-1"},
|
||||
{"role":"user","content":"second","id":"msg-2"},
|
||||
{"role":"assistant","content":"answer 2","id":"msg-2"}
|
||||
]`),
|
||||
Reference: json.RawMessage(`[
|
||||
{"chunks":[{"id":"chunk-1","kb_id":"kb-1"}]},
|
||||
{"chunks":[{"id":"chunk-2","kb_id":"kb-2"}]}
|
||||
]`),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"},{"role":"user","content":"first","id":"msg-1"},{"role":"assistant","content":"answer 1","id":"msg-1","reference":{"chunks":[{"id":"chunk-1","kb_id":"kb-1"}]}},{"role":"user","content":"second","id":"msg-2"},{"role":"assistant","content":"answer 2","id":"msg-2","reference":{"chunks":[{"id":"chunk-2","kb_id":"kb-2"}]}}]`),
|
||||
}
|
||||
store.dialogExists["tenant-1|chat-1"] = true
|
||||
|
||||
@@ -880,12 +863,16 @@ func TestDeleteSessionMessage_RemovesMessagePairAndReference(t *testing.T) {
|
||||
if resp.Messages[1]["id"] != "msg-2" || resp.Messages[2]["id"] != "msg-2" {
|
||||
t.Fatalf("remaining pair=%#v", resp.Messages)
|
||||
}
|
||||
if len(resp.Reference) != 1 {
|
||||
t.Fatalf("reference=%#v", resp.Reference)
|
||||
if _, ok := resp.Messages[2]["reference"]; ok {
|
||||
t.Fatalf("delete response contains reference: %#v", resp.Messages[2])
|
||||
}
|
||||
ref, ok := resp.Reference[0].(map[string]interface{})
|
||||
resp, code, err = svc.GetSession(ctx, "user-1", "chat-1", "session-1", dao.ConversationHistoryOptions{IncludeReference: true})
|
||||
if err != nil || code != common.CodeSuccess {
|
||||
t.Fatalf("GetSession: code=%v err=%v", code, err)
|
||||
}
|
||||
ref, ok := resp.Messages[2]["reference"].(map[string]interface{})
|
||||
if !ok {
|
||||
t.Fatalf("reference type=%T", resp.Reference[0])
|
||||
t.Fatalf("reference type=%T", resp.Messages[2]["reference"])
|
||||
}
|
||||
chunks, ok := ref["chunks"].([]FormattedChunk)
|
||||
if !ok || len(chunks) != 1 || chunks[0].ID != "chunk-2" {
|
||||
@@ -902,14 +889,7 @@ func TestUpdateMessageFeedback_AppliesChunkFeedbackWithResolvedTenantAndContext(
|
||||
ID: "session-1",
|
||||
DialogID: "chat-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[
|
||||
{"role":"assistant","content":"Welcome!"},
|
||||
{"role":"user","content":"question","id":"msg-1"},
|
||||
{"role":"assistant","content":"answer","id":"msg-1","feedback":"old"}
|
||||
]`),
|
||||
Reference: json.RawMessage(`[
|
||||
{"chunks":[{"id":"chunk-1","kb_id":"kb-1","similarity":0.9}]}
|
||||
]`),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"},{"role":"user","content":"question","id":"msg-1"},{"role":"assistant","content":"answer","id":"msg-1","feedback":"old","reference":{"chunks":[{"id":"chunk-1","kb_id":"kb-1","similarity":0.9}]}}]`),
|
||||
}
|
||||
store.dialogExists["tenant-owner|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", TenantID: "tenant-owner"}
|
||||
@@ -962,14 +942,7 @@ func TestUpdateMessageFeedback_ToggleUsesResolvedTenantForUndoAndApply(t *testin
|
||||
ID: "session-1",
|
||||
DialogID: "chat-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[
|
||||
{"role":"assistant","content":"Welcome!"},
|
||||
{"role":"user","content":"question","id":"msg-1"},
|
||||
{"role":"assistant","content":"answer","id":"msg-1","thumbup":true}
|
||||
]`),
|
||||
Reference: json.RawMessage(`[
|
||||
{"chunks":[{"chunk_id":"chunk-1","dataset_id":"kb-1"}]}
|
||||
]`),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"},{"role":"user","content":"question","id":"msg-1"},{"role":"assistant","content":"answer","id":"msg-1","thumbup":true,"reference":{"chunks":[{"chunk_id":"chunk-1","dataset_id":"kb-1"}]}}]`),
|
||||
}
|
||||
store.dialogExists["tenant-owner|chat-1"] = true
|
||||
store.dialogs["chat-1"] = &entity.Chat{ID: "chat-1", TenantID: "tenant-owner"}
|
||||
@@ -1140,8 +1113,7 @@ 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(`[]`),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
|
||||
}
|
||||
store.dialogExists["user-1|dialog-1"] = true
|
||||
store.sessions["session-1"] = session
|
||||
@@ -1207,7 +1179,7 @@ func TestChatCompletions_AppendOnly(t *testing.T) {
|
||||
|
||||
t.Run("non-storing model tests", func(t *testing.T) {
|
||||
stored := store.sessions["session-1"]
|
||||
storedMessages, storedReference := string(stored.Message), string(stored.Reference)
|
||||
storedMessages := string(stored.Message)
|
||||
payload := []map[string]interface{}{
|
||||
{"role": "system", "content": "client system prompt"},
|
||||
{"role": "user", "content": "earlier question"},
|
||||
@@ -1254,7 +1226,7 @@ func TestChatCompletions_AppendOnly(t *testing.T) {
|
||||
}
|
||||
wg.Wait()
|
||||
stored = store.sessions["session-1"]
|
||||
if len(store.updateCalled) != 2 || len(store.createCalled) != 0 || string(stored.Message) != storedMessages || string(stored.Reference) != storedReference {
|
||||
if len(store.updateCalled) != 2 || len(store.createCalled) != 0 || string(stored.Message) != storedMessages {
|
||||
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 {
|
||||
@@ -1266,11 +1238,10 @@ func TestChatCompletions_AppendOnly(t *testing.T) {
|
||||
func TestChatCompletionsPassesRequestUserIDToPipeline(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.sessions["session-1"] = &entity.ChatSession{
|
||||
ID: "session-1",
|
||||
DialogID: "dialog-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: "session-1",
|
||||
DialogID: "dialog-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
|
||||
}
|
||||
store.dialogs["dialog-1"] = &entity.Chat{
|
||||
ID: "dialog-1",
|
||||
@@ -1324,11 +1295,10 @@ func TestChatCompletionsPassesRequestUserIDToPipeline(t *testing.T) {
|
||||
func TestChatCompletionsStreamFinalCarriesDecoratedReference(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.sessions["session-1"] = &entity.ChatSession{
|
||||
ID: "session-1",
|
||||
DialogID: "dialog-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: "session-1",
|
||||
DialogID: "dialog-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[{"role":"assistant","content":"Welcome!"}]`),
|
||||
}
|
||||
store.dialogs["dialog-1"] = &entity.Chat{
|
||||
ID: "dialog-1",
|
||||
@@ -1448,11 +1418,10 @@ func TestChatCompletionsStreamFinalCarriesDecoratedReference(t *testing.T) {
|
||||
func TestChatCompletionsModelIDOverrideUsesModelResolver(t *testing.T) {
|
||||
store := newFakeSessionStore()
|
||||
store.sessions["session-1"] = &entity.ChatSession{
|
||||
ID: "session-1",
|
||||
DialogID: "dialog-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[]`),
|
||||
Reference: json.RawMessage(`[]`),
|
||||
ID: "session-1",
|
||||
DialogID: "dialog-1",
|
||||
UserID: strPtr("user-1"),
|
||||
Message: json.RawMessage(`[]`),
|
||||
}
|
||||
store.dialogs["dialog-1"] = &entity.Chat{
|
||||
ID: "dialog-1",
|
||||
@@ -1540,18 +1509,14 @@ func TestParseMessages_LegacyWrappedObject(t *testing.T) {
|
||||
func TestBuildSessionPayload_EmptyCollectionsEncodeAsEmptyArrays(t *testing.T) {
|
||||
svc := &ChatSessionService{}
|
||||
payload := svc.buildSessionPayload(&entity.ChatSession{
|
||||
ID: "session-1",
|
||||
DialogID: "chat-1",
|
||||
Message: nil,
|
||||
Reference: json.RawMessage(`null`),
|
||||
ID: "session-1",
|
||||
DialogID: "chat-1",
|
||||
Message: nil,
|
||||
}, nil, false)
|
||||
|
||||
if payload.Messages == nil {
|
||||
t.Fatal("messages is nil")
|
||||
}
|
||||
if payload.Reference == nil {
|
||||
t.Fatal("reference is nil")
|
||||
}
|
||||
|
||||
body, err := json.Marshal(payload)
|
||||
if err != nil {
|
||||
@@ -1560,8 +1525,8 @@ func TestBuildSessionPayload_EmptyCollectionsEncodeAsEmptyArrays(t *testing.T) {
|
||||
if !strings.Contains(string(body), `"messages":[]`) {
|
||||
t.Fatalf("messages did not encode as empty array: %s", string(body))
|
||||
}
|
||||
if !strings.Contains(string(body), `"reference":[]`) {
|
||||
t.Fatalf("reference did not encode as empty array: %s", string(body))
|
||||
if strings.Contains(string(body), `"reference":`) {
|
||||
t.Fatalf("unexpected session reference: %s", string(body))
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1578,16 +1543,6 @@ func TestParseCollections_ReturnEmptySlicesForMissingOrNull(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
referenceInputs := []json.RawMessage{
|
||||
nil,
|
||||
json.RawMessage(`null`),
|
||||
}
|
||||
for _, input := range referenceInputs {
|
||||
got := parseReferenceList(input)
|
||||
if got == nil || len(got) != 0 {
|
||||
t.Fatalf("parseReferenceList(%s)=%#v", string(input), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseCollections_ReturnNilForMalformedData(t *testing.T) {
|
||||
@@ -1601,15 +1556,6 @@ func TestParseCollections_ReturnNilForMalformedData(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
referenceInputs := []json.RawMessage{
|
||||
json.RawMessage(`not-json`),
|
||||
json.RawMessage(`{"unexpected":[]}`),
|
||||
}
|
||||
for _, input := range referenceInputs {
|
||||
if got := parseReferenceList(input); got != nil {
|
||||
t.Fatalf("parseReferenceList(%s)=%#v, want nil", string(input), got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ===================================================================
|
||||
|
||||
@@ -0,0 +1,60 @@
|
||||
import { QueryClient, QueryClientProvider } from '@tanstack/react-query';
|
||||
import { act, renderHook } from '@testing-library/react';
|
||||
import { useFetchSessionManually as useAgentSession } from '../use-agent-request';
|
||||
import { useFetchSessionManually as useChatSession } from '../use-chat-request';
|
||||
|
||||
jest.mock('react-router', () => ({
|
||||
...jest.requireActual('react-router'),
|
||||
useParams: () => ({ id: 'resource' }),
|
||||
}));
|
||||
jest.mock('@/services/next-chat-service', () => ({
|
||||
__esModule: true,
|
||||
default: { getSession: jest.fn() },
|
||||
}));
|
||||
jest.mock('@/services/agent-service', () => ({
|
||||
__esModule: true,
|
||||
default: {},
|
||||
fetchAgentLogsById: jest.fn(),
|
||||
}));
|
||||
|
||||
const chatService = require('@/services/next-chat-service').default;
|
||||
const agentService = require('@/services/agent-service');
|
||||
|
||||
const wrapper = ({ children }: { children: React.ReactNode }) => (
|
||||
<QueryClientProvider client={new QueryClient()}>
|
||||
{children}
|
||||
</QueryClientProvider>
|
||||
);
|
||||
|
||||
it('requests references and prompts for chat history displayed in the UI', async () => {
|
||||
chatService.getSession.mockResolvedValue({
|
||||
data: { data: { messages: [] } },
|
||||
});
|
||||
const { result } = renderHook(() => useChatSession(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSessionManually('session');
|
||||
});
|
||||
|
||||
expect(chatService.getSession).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
params: { include_reference: true, include_prompt: true },
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
it('requests references and prompts for agent history displayed in the UI', async () => {
|
||||
agentService.fetchAgentLogsById.mockResolvedValue({ data: { data: {} } });
|
||||
const { result } = renderHook(() => useAgentSession(), { wrapper });
|
||||
|
||||
await act(async () => {
|
||||
await result.current.fetchSessionManually('session');
|
||||
});
|
||||
|
||||
expect(agentService.fetchAgentLogsById).toHaveBeenCalledWith(
|
||||
'resource',
|
||||
'session',
|
||||
{ include_reference: true, include_prompt: true },
|
||||
);
|
||||
});
|
||||
@@ -1002,6 +1002,7 @@ export const useFetchAgentLog = (searchParams: IAgentLogsRequest) => {
|
||||
queryFn: async () => {
|
||||
const { data } = await fetchAgentLogsByCanvasId(id as string, {
|
||||
...searchParams,
|
||||
include_reference: true,
|
||||
});
|
||||
|
||||
return { total: data?.total ?? 0, sessions: data?.data ?? [] };
|
||||
@@ -1027,6 +1028,7 @@ export const useFetchSessionsByCanvasId = () => {
|
||||
fetchPage: async (params) => {
|
||||
const { data } = await fetchAgentLogsByCanvasId(canvasId!, {
|
||||
...params,
|
||||
include_history: false,
|
||||
exp_user_id: tenantInfo.tenant_id,
|
||||
});
|
||||
|
||||
@@ -1439,7 +1441,10 @@ export function useFetchSessionManually() {
|
||||
return null;
|
||||
}
|
||||
|
||||
const { data } = await fetchAgentLogsById(canvasId, sessionId);
|
||||
const { data } = await fetchAgentLogsById(canvasId, sessionId, {
|
||||
include_reference: true,
|
||||
include_prompt: true,
|
||||
});
|
||||
|
||||
return data?.data;
|
||||
},
|
||||
|
||||
@@ -368,7 +368,10 @@ export function useFetchSessionManually() {
|
||||
mutationKey: [ChatApiAction.FetchSessionManually],
|
||||
mutationFn: async (sessionId) => {
|
||||
const { data } = await chatService.getSession(
|
||||
{ url: api.getSession(chatId!, sessionId) },
|
||||
{
|
||||
url: api.getSession(chatId!, sessionId),
|
||||
params: { include_reference: true, include_prompt: true },
|
||||
},
|
||||
true,
|
||||
);
|
||||
|
||||
|
||||
@@ -307,7 +307,6 @@ export interface IAgentLogResponse {
|
||||
source: string;
|
||||
user_id: string;
|
||||
dsl: string;
|
||||
reference: IReference[];
|
||||
name: string;
|
||||
version_title: string;
|
||||
is_new?: boolean;
|
||||
@@ -317,6 +316,9 @@ export interface IAgentLogsResponse {
|
||||
sessions: IAgentLogResponse[];
|
||||
}
|
||||
export interface IAgentLogsRequest {
|
||||
include_history?: boolean;
|
||||
include_reference?: boolean;
|
||||
include_prompt?: boolean;
|
||||
keywords?: string;
|
||||
to_date?: string | Date;
|
||||
from_date?: string | Date;
|
||||
@@ -330,6 +332,7 @@ export interface IAgentLogsRequest {
|
||||
export interface IAgentLogMessage {
|
||||
content: string;
|
||||
role: 'user' | 'assistant';
|
||||
reference?: IReference;
|
||||
id: string;
|
||||
}
|
||||
|
||||
|
||||
@@ -131,7 +131,6 @@ export interface IConversation {
|
||||
id: string;
|
||||
avatar: string;
|
||||
messages: Message[];
|
||||
reference: IReference[];
|
||||
name: string;
|
||||
update_date: string;
|
||||
update_time: number;
|
||||
@@ -143,6 +142,7 @@ export interface Message {
|
||||
role: MessageType;
|
||||
doc_ids?: string[];
|
||||
prompt?: string;
|
||||
reference?: IReference;
|
||||
id?: string;
|
||||
audio_binary?: string;
|
||||
data?: any;
|
||||
@@ -155,6 +155,7 @@ export interface Message {
|
||||
}
|
||||
|
||||
export interface IReferenceChunk {
|
||||
citation_id?: string;
|
||||
id: string;
|
||||
content: null;
|
||||
document_id: string;
|
||||
@@ -254,7 +255,6 @@ export interface IExternalChatInfo {
|
||||
export interface IMessage extends Message {
|
||||
id: string;
|
||||
awaitingServerId?: boolean;
|
||||
reference?: IReference; // the latest news has reference
|
||||
conversationId?: string; // To distinguish which conversation the message belongs to
|
||||
}
|
||||
|
||||
|
||||
@@ -8,12 +8,12 @@ import { MessageType } from '@/constants/chat';
|
||||
import { useUploadAgentFileWithProgress } from '@/hooks/use-agent-request';
|
||||
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
|
||||
import { IAgentLogResponse } from '@/interfaces/database/agent';
|
||||
import { IMessage, IReferenceObject } from '@/interfaces/database/chat';
|
||||
import { IMessage } from '@/interfaces/database/chat';
|
||||
import DebugContent from '@/pages/agent/debug-content';
|
||||
import { useAwaitComponentData } from '@/pages/agent/hooks/use-chat-logic';
|
||||
import { BeginQuery } from '@/pages/agent/interface';
|
||||
import { ParameterDialog } from '@/pages/agent/share/parameter-dialog';
|
||||
import { buildMessageUuidWithRole } from '@/utils/chat';
|
||||
import { buildMessageUuidWithRole, buildReferenceObject } from '@/utils/chat';
|
||||
import { useCallback, useEffect, useRef } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { useExploreUrlParams } from '../hooks/use-explore-url-params';
|
||||
@@ -181,10 +181,7 @@ export function SessionChat({ session }: SessionChatProps) {
|
||||
avatarDialog={canvasInfo?.avatar || ''}
|
||||
reference={
|
||||
findReferenceByMessageId(message.id) ||
|
||||
(session?.reference?.[
|
||||
Math.floor((i - 1) / 2)
|
||||
] as unknown as IReferenceObject) ||
|
||||
{}
|
||||
buildReferenceObject(message.reference)
|
||||
}
|
||||
clickDocumentButton={clickDocumentButton}
|
||||
index={i}
|
||||
|
||||
@@ -34,7 +34,6 @@ export const useSelectDerivedSessionList = () => {
|
||||
source: '',
|
||||
user_id: '',
|
||||
dsl: '',
|
||||
reference: [],
|
||||
is_new: true,
|
||||
name: '',
|
||||
version_title: '',
|
||||
|
||||
@@ -1,24 +1,4 @@
|
||||
import { MessageType } from '@/constants/chat';
|
||||
import { IEventList, MessageEventType } from '@/hooks/use-send-message';
|
||||
import { IMessage, IReference } from '@/interfaces/database/chat';
|
||||
import { isEmpty } from 'lodash';
|
||||
|
||||
export const buildAgentMessageItemReference = (
|
||||
conversation: { messages: IMessage[]; reference: IReference[] },
|
||||
message: IMessage,
|
||||
) => {
|
||||
const assistantMessages = conversation.messages?.filter(
|
||||
(x) => x.role === MessageType.Assistant,
|
||||
);
|
||||
const referenceIndex = assistantMessages.findIndex(
|
||||
(x) => x.id === message.id,
|
||||
);
|
||||
const reference = !isEmpty(message?.reference)
|
||||
? message?.reference
|
||||
: (conversation?.reference ?? [])[referenceIndex];
|
||||
|
||||
return reference ?? { doc_aggs: [], chunks: [], total: 0 };
|
||||
};
|
||||
|
||||
/**
|
||||
* Determines whether the message should be split into two separate entries:
|
||||
|
||||
@@ -3,86 +3,16 @@ import { Modal } from '@/components/ui/modal/modal';
|
||||
import { useFetchAgent } from '@/hooks/use-agent-request';
|
||||
import { useFetchUserInfo } from '@/hooks/use-user-setting-request';
|
||||
import { IAgentLogMessage } from '@/interfaces/database/agent';
|
||||
import {
|
||||
Docagg,
|
||||
IMessage,
|
||||
IReferenceChunk,
|
||||
IReferenceObject,
|
||||
Message,
|
||||
} from '@/interfaces/database/chat';
|
||||
import { buildMessageUuidWithRole } from '@/utils/chat';
|
||||
import { IMessage, Message } from '@/interfaces/database/chat';
|
||||
import { buildMessageUuidWithRole, buildReferenceObject } from '@/utils/chat';
|
||||
import React, { useMemo } from 'react';
|
||||
|
||||
interface CustomModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
message: IAgentLogMessage[];
|
||||
reference: IReferenceObject;
|
||||
}
|
||||
|
||||
interface IAgentLogReferenceChunk {
|
||||
citation_id?: string;
|
||||
content?: string | null;
|
||||
document_id: string;
|
||||
document_name: string;
|
||||
dataset_id: string;
|
||||
id: string;
|
||||
image_id: string;
|
||||
positions?: number[] | number[][];
|
||||
similarity?: number;
|
||||
vector_similarity?: number;
|
||||
term_similarity?: number;
|
||||
doc_type?: string;
|
||||
url?: string;
|
||||
}
|
||||
|
||||
// Runtime agent-log messages carry the reference chunks array directly,
|
||||
// even though IAgentLogMessage does not declare it.
|
||||
type AgentLogMessageWithReference = IAgentLogMessage & {
|
||||
reference?: IAgentLogReferenceChunk[];
|
||||
};
|
||||
|
||||
const buildReferenceObject = (
|
||||
chunks: IAgentLogReferenceChunk[] | undefined | null,
|
||||
): IReferenceObject => {
|
||||
if (!chunks || !Array.isArray(chunks) || chunks.length === 0) {
|
||||
return { chunks: {}, doc_aggs: {} };
|
||||
}
|
||||
|
||||
const chunkMap: Record<string, IReferenceChunk> = {};
|
||||
const docAggMap: Record<string, Docagg> = {};
|
||||
|
||||
chunks.forEach((chunk, index) => {
|
||||
const key = chunk.citation_id ?? String(index);
|
||||
|
||||
chunkMap[key] = {
|
||||
id: chunk.id,
|
||||
content: (chunk.content ?? null) as IReferenceChunk['content'],
|
||||
document_id: chunk.document_id,
|
||||
document_name: chunk.document_name,
|
||||
dataset_id: chunk.dataset_id,
|
||||
image_id: chunk.image_id,
|
||||
similarity: chunk.similarity ?? 0,
|
||||
vector_similarity: chunk.vector_similarity ?? 0,
|
||||
term_similarity: chunk.term_similarity ?? 0,
|
||||
positions: (chunk.positions ?? []) as IReferenceChunk['positions'],
|
||||
doc_type: chunk.doc_type,
|
||||
};
|
||||
|
||||
if (!docAggMap[chunk.document_id]) {
|
||||
docAggMap[chunk.document_id] = {
|
||||
count: 0,
|
||||
doc_id: chunk.document_id,
|
||||
doc_name: chunk.document_name,
|
||||
url: chunk.url,
|
||||
};
|
||||
}
|
||||
docAggMap[chunk.document_id].count++;
|
||||
});
|
||||
|
||||
return { chunks: chunkMap, doc_aggs: docAggMap };
|
||||
};
|
||||
|
||||
export const AgentLogDetailModal: React.FC<CustomModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
@@ -125,7 +55,6 @@ export const AgentLogDetailModal: React.FC<CustomModalProps> = ({
|
||||
<div className="flex items-start mb-4 flex-col gap-4 justify-start">
|
||||
<div className="w-full">
|
||||
{derivedMessages?.map((message, i) => {
|
||||
const msg = message as AgentLogMessageWithReference;
|
||||
return (
|
||||
<MessageItem
|
||||
key={buildMessageUuidWithRole(
|
||||
@@ -135,7 +64,7 @@ export const AgentLogDetailModal: React.FC<CustomModalProps> = ({
|
||||
avatar={userInfo.avatar}
|
||||
avatarDialog={canvasInfo.avatar}
|
||||
item={message as IMessage}
|
||||
reference={buildReferenceObject(msg.reference)}
|
||||
reference={buildReferenceObject(message.reference)}
|
||||
index={i}
|
||||
showLikeButton={false}
|
||||
showLog={false}
|
||||
|
||||
@@ -18,7 +18,6 @@ import {
|
||||
IAgentLogMessage,
|
||||
IAgentLogResponse,
|
||||
} from '@/interfaces/database/agent';
|
||||
import { IReferenceObject } from '@/interfaces/database/chat';
|
||||
import { formatDate } from '@/utils/date';
|
||||
import React, { useCallback, useEffect, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
@@ -415,7 +414,6 @@ const AgentLogPage: React.FC = () => {
|
||||
<AgentLogDetailModal
|
||||
isOpen={openModal}
|
||||
message={modalData?.message as IAgentLogMessage[]}
|
||||
reference={modalData?.reference as unknown as IReferenceObject}
|
||||
onClose={() => setOpenModal(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
@@ -94,7 +94,6 @@ const ChatCard = forwardRef(function ChatCard(
|
||||
addChatBox,
|
||||
chatBoxIds,
|
||||
clickDocumentButton,
|
||||
conversation,
|
||||
value,
|
||||
setValue,
|
||||
files,
|
||||
@@ -120,10 +119,7 @@ const ChatCard = forwardRef(function ChatCard(
|
||||
|
||||
const { scrollRef } = useScrollToBottom(derivedMessages, messageContainerRef);
|
||||
|
||||
const messageReferences = useMessageReferences(
|
||||
derivedMessages,
|
||||
conversation.reference,
|
||||
);
|
||||
const messageReferences = useMessageReferences(derivedMessages);
|
||||
|
||||
const FormSchema = z.object(LlmSettingSchema);
|
||||
|
||||
|
||||
@@ -75,7 +75,6 @@ function buildConversation(id: string) {
|
||||
update_date: '',
|
||||
update_time: 0,
|
||||
is_new: true as const,
|
||||
reference: [],
|
||||
messages: [
|
||||
{
|
||||
id: 'old-message',
|
||||
|
||||
@@ -51,10 +51,7 @@ export function SingleChatBox({ conversation }: IProps) {
|
||||
|
||||
const showInternet = useShowInternet();
|
||||
|
||||
const messageReferences = useMessageReferences(
|
||||
messages,
|
||||
conversation.reference,
|
||||
);
|
||||
const messageReferences = useMessageReferences(messages);
|
||||
|
||||
useEffect(() => {
|
||||
// Skip when the conversation prop is stale — its id doesn't match the
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
import { MessageType } from '@/constants/chat';
|
||||
import type { IMessage, IReference } from '@/interfaces/database/chat';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
import { EmptyReference } from '../utils';
|
||||
import { useMessageReferences } from './use-message-references';
|
||||
|
||||
it('resolves each answer reference independently of shared ids or answer order', () => {
|
||||
const reference: IReference = { chunks: [], doc_aggs: [], total: 1 };
|
||||
const messages: IMessage[] = [
|
||||
{ id: 'turn', role: MessageType.User, content: 'question' },
|
||||
{ id: 'turn', role: MessageType.Assistant, content: 'answer', reference },
|
||||
{ id: 'next', role: MessageType.Assistant, content: '' },
|
||||
];
|
||||
const { result, rerender } = renderHook(() => useMessageReferences(messages));
|
||||
|
||||
expect(result.current.get(messages[0])).toBe(EmptyReference);
|
||||
expect(result.current.get(messages[1])).toBe(reference);
|
||||
expect(result.current.get(messages[2])).toBe(EmptyReference);
|
||||
const resolved = result.current;
|
||||
rerender();
|
||||
expect(result.current).toBe(resolved);
|
||||
});
|
||||
|
||||
it('handles missing history', () => {
|
||||
const { result } = renderHook(() => useMessageReferences(undefined));
|
||||
expect(result.current.size).toBe(0);
|
||||
});
|
||||
@@ -1,71 +1,14 @@
|
||||
import { MessageType } from '@/constants/chat';
|
||||
import { IMessage, IReference } from '@/interfaces/database/chat';
|
||||
import { isEmpty } from 'lodash';
|
||||
import { useMemo } from 'react';
|
||||
import { EmptyReference } from '../utils';
|
||||
|
||||
/**
|
||||
* Resolves the reference of every message in one pass and caches the result
|
||||
* until the message list or the conversation's reference list changes.
|
||||
*
|
||||
* Doing this per message inside the render loop (the previous
|
||||
* `buildMessageItemReference` call site) was quadratic, and — worse — handed
|
||||
* `MessageItem` a freshly allocated object on every streaming flush, which
|
||||
* defeated its `memo` and re-parsed the markdown of the whole transcript
|
||||
* ~16 times a second.
|
||||
*
|
||||
* Keyed by message object rather than by id: a question and its answer share an
|
||||
* id, so an id-keyed map could not tell them apart.
|
||||
*/
|
||||
export function useMessageReferences(
|
||||
messages: IMessage[] | undefined,
|
||||
reference: IReference[] | undefined,
|
||||
) {
|
||||
// Key by object because a question and its answer share the same message id.
|
||||
export function useMessageReferences(messages: IMessage[] | undefined) {
|
||||
return useMemo(() => {
|
||||
const list = messages ?? [];
|
||||
const references = reference ?? [];
|
||||
|
||||
// Index of each assistant message within the answer sequence, skipping the
|
||||
// prologue (which has no reference) and error answers. First id wins, which
|
||||
// is what the previous `findIndex` lookup did.
|
||||
const answerIndexById = new Map<string, number>();
|
||||
let answerCount = 0;
|
||||
list.forEach((message) => {
|
||||
if (
|
||||
message.role !== MessageType.Assistant ||
|
||||
message.content?.startsWith('**ERROR**:')
|
||||
) {
|
||||
return;
|
||||
}
|
||||
if (answerCount > 0 && !answerIndexById.has(message.id)) {
|
||||
answerIndexById.set(message.id, answerCount - 1);
|
||||
}
|
||||
answerCount += 1;
|
||||
});
|
||||
|
||||
const resolved = new Map<IMessage, IReference>();
|
||||
list.forEach((message) => {
|
||||
if (!isEmpty(message.reference)) {
|
||||
resolved.set(message, message.reference as IReference);
|
||||
return;
|
||||
}
|
||||
// An assistant message that has not received any content yet is still
|
||||
// being generated. Never resolve its reference from the conversation
|
||||
// reference list — the indices only align with completed answers, so the
|
||||
// lookup would surface a stale reference from a previous turn (or from a
|
||||
// previously opened conversation) while the answer is streaming.
|
||||
if (message.role === MessageType.Assistant && !message.content) {
|
||||
resolved.set(message, EmptyReference);
|
||||
return;
|
||||
}
|
||||
const answerIndex = answerIndexById.get(message.id);
|
||||
resolved.set(
|
||||
message,
|
||||
(answerIndex === undefined ? undefined : references[answerIndex]) ??
|
||||
EmptyReference,
|
||||
);
|
||||
(messages ?? []).forEach((message) => {
|
||||
resolved.set(message, message.reference ?? EmptyReference);
|
||||
});
|
||||
|
||||
return resolved;
|
||||
}, [messages, reference]);
|
||||
}, [messages]);
|
||||
}
|
||||
@@ -43,7 +43,7 @@ const ChatContainer = () => {
|
||||
const sendDisabled = useSendButtonDisabled(value);
|
||||
const { data: chatInfo } = useFetchExternalChatInfo();
|
||||
|
||||
const messageReferences = useMessageReferences(derivedMessages, undefined);
|
||||
const messageReferences = useMessageReferences(derivedMessages);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (locale && i18n.language !== locale) {
|
||||
|
||||
@@ -26,19 +26,16 @@ export const isConversationIdExist = (conversationId: string) => {
|
||||
};
|
||||
|
||||
export const getDocumentIdsFromConversionReference = (data: IConversation) => {
|
||||
const documentIds = data.reference.reduce(
|
||||
(pre: Array<string>, cur: IReference) => {
|
||||
cur.doc_aggs
|
||||
?.map((x) => x.doc_id)
|
||||
.forEach((x) => {
|
||||
if (pre.every((y) => y !== x)) {
|
||||
pre.push(x);
|
||||
}
|
||||
});
|
||||
return pre;
|
||||
},
|
||||
[],
|
||||
);
|
||||
const documentIds = data.messages.reduce((pre: Array<string>, cur) => {
|
||||
cur.reference?.doc_aggs
|
||||
?.map((x) => x.doc_id)
|
||||
.forEach((x) => {
|
||||
if (pre.every((y) => y !== x)) {
|
||||
pre.push(x);
|
||||
}
|
||||
});
|
||||
return pre;
|
||||
}, []);
|
||||
return documentIds.join(',');
|
||||
};
|
||||
|
||||
|
||||
@@ -251,8 +251,14 @@ export const fetchAgentLogsByCanvasId = (
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchAgentLogsById = (canvasId: string, sessionId: string) => {
|
||||
return request.get(api.fetchAgentSessionById(canvasId, sessionId));
|
||||
export const fetchAgentLogsById = (
|
||||
canvasId: string,
|
||||
sessionId: string,
|
||||
params?: Pick<IAgentLogsRequest, 'include_reference' | 'include_prompt'>,
|
||||
) => {
|
||||
return request.get(api.fetchAgentSessionById(canvasId, sessionId), {
|
||||
params,
|
||||
});
|
||||
};
|
||||
|
||||
export const fetchPipeLineList = (params: IPipeLineListRequest) => {
|
||||
|
||||
@@ -1,4 +1,8 @@
|
||||
import { MessageType } from '@/constants/chat';
|
||||
import type { IReference } from '@/interfaces/database/chat';
|
||||
import {
|
||||
buildMessageListWithUuid,
|
||||
buildReferenceObject,
|
||||
decodeAngleBracketEntities,
|
||||
escapeUnmatchedAngleBrackets,
|
||||
mergeAnswerChunk,
|
||||
@@ -168,3 +172,39 @@ describe('replaceThinkToSection', () => {
|
||||
expect(replaceThinkToSection('plain answer')).toBe('plain answer');
|
||||
});
|
||||
});
|
||||
|
||||
describe('message references', () => {
|
||||
it('preserves persisted references when assigning message ids', () => {
|
||||
const reference: IReference = { chunks: [], doc_aggs: [], total: 0 };
|
||||
const messages = buildMessageListWithUuid([
|
||||
{ role: MessageType.Assistant, content: 'answer', reference },
|
||||
]);
|
||||
expect(messages[0].id).toBeTruthy();
|
||||
expect(messages[0].reference).toBe(reference);
|
||||
});
|
||||
|
||||
it('maps persisted citations and documents for the agent renderer', () => {
|
||||
const chunk = {
|
||||
id: 'chunk',
|
||||
content: null,
|
||||
citation_id: 'citation',
|
||||
document_id: 'document',
|
||||
document_name: 'Document',
|
||||
dataset_id: 'dataset',
|
||||
image_id: '',
|
||||
similarity: 1,
|
||||
vector_similarity: 1,
|
||||
term_similarity: 1,
|
||||
positions: [],
|
||||
};
|
||||
const document = { doc_id: 'document', doc_name: 'Document', count: 1 };
|
||||
const reference = buildReferenceObject({
|
||||
chunks: [chunk],
|
||||
doc_aggs: [document],
|
||||
total: 1,
|
||||
});
|
||||
expect(reference.chunks.citation).toBe(chunk);
|
||||
expect(reference.doc_aggs.document).toBe(document);
|
||||
expect(buildReferenceObject()).toEqual({ chunks: {}, doc_aggs: {} });
|
||||
});
|
||||
});
|
||||
+21
-3
@@ -18,9 +18,13 @@ import {
|
||||
ChatVariableEnabledField,
|
||||
EmptyConversationId,
|
||||
} from '@/constants/chat';
|
||||
import { IMessage, Message } from '@/interfaces/database/chat';
|
||||
import {
|
||||
IMessage,
|
||||
IReference,
|
||||
IReferenceObject,
|
||||
Message,
|
||||
} from '@/interfaces/database/chat';
|
||||
import type { CompletionChunk } from '@/services/chat-completion-stream';
|
||||
import { omit } from 'lodash';
|
||||
import { v4 as uuid } from 'uuid';
|
||||
import {
|
||||
buildCitationDisplayMap,
|
||||
@@ -44,12 +48,26 @@ export const buildMessageUuid = (message: Partial<Message | IMessage>) => {
|
||||
export const buildMessageListWithUuid = (messages?: Message[]) => {
|
||||
return (
|
||||
messages?.map((x: Message | IMessage) => ({
|
||||
...omit(x, 'reference'),
|
||||
...x,
|
||||
id: buildMessageUuid(x),
|
||||
})) ?? []
|
||||
);
|
||||
};
|
||||
|
||||
export const buildReferenceObject = (
|
||||
reference?: IReference,
|
||||
): IReferenceObject => ({
|
||||
chunks: Object.fromEntries(
|
||||
(reference?.chunks ?? []).map((chunk, index) => [
|
||||
chunk.citation_id ?? String(index),
|
||||
chunk,
|
||||
]),
|
||||
),
|
||||
doc_aggs: Object.fromEntries(
|
||||
(reference?.doc_aggs ?? []).map((document) => [document.doc_id, document]),
|
||||
),
|
||||
});
|
||||
|
||||
export const generateConversationId = () => {
|
||||
return uuid().replace(/-/g, '');
|
||||
};
|
||||
|
||||
Reference in new issue
Block a user