Version: 0.8.1.dev.260326
后端: 1.获取agent聊天历史记录接口做了如下更改: (1)对reasoning_content也做了存储,同步更改了mysql和redis缓存的读写逻辑 (2)为了承接前端的重试/修改消息的逻辑,进行了一些代码和表单上的改动 前端: 1.agent页面新增了很多小组件,改善交互体验 2.新增重试消息/修改消息并重新发送功能,前者有bug,可能前后端都有问题,待修复。
This commit is contained in:
@@ -29,7 +29,8 @@ func StreamChat(
|
||||
traceID string,
|
||||
chatID string,
|
||||
requestStart time.Time,
|
||||
) (string, *schema.TokenUsage, error) {
|
||||
reasoningStartAt *time.Time,
|
||||
) (string, string, int, *schema.TokenUsage, error) {
|
||||
/*callStart := time.Now()*/
|
||||
|
||||
messages := make([]*schema.Message, 0)
|
||||
@@ -49,7 +50,7 @@ func StreamChat(
|
||||
/*connectStart := time.Now()*/
|
||||
reader, err := llm.Stream(ctx, messages, ark.WithThinking(thinking))
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
defer reader.Close()
|
||||
|
||||
@@ -61,6 +62,12 @@ func StreamChat(
|
||||
firstChunk := true
|
||||
chunkCount := 0
|
||||
var tokenUsage *schema.TokenUsage
|
||||
var localReasoningStartAt *time.Time
|
||||
if reasoningStartAt != nil && !reasoningStartAt.IsZero() {
|
||||
startCopy := reasoningStartAt.In(time.Local)
|
||||
localReasoningStartAt = &startCopy
|
||||
}
|
||||
var reasoningEndAt *time.Time
|
||||
/*streamRecvStart := time.Now()
|
||||
|
||||
log.Printf("打点|流连接建立|trace_id=%s|chat_id=%s|request_id=%s|本步耗时_ms=%d|请求累计_ms=%d|history_len=%d",
|
||||
@@ -73,13 +80,14 @@ func StreamChat(
|
||||
)*/
|
||||
|
||||
var fullText strings.Builder
|
||||
var reasoningText strings.Builder
|
||||
for {
|
||||
chunk, err := reader.Recv()
|
||||
if err == io.EOF {
|
||||
break
|
||||
}
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
|
||||
// 优先记录模型真实 usage(通常在尾块返回,部分模型也可能中途返回)。
|
||||
@@ -87,11 +95,22 @@ func StreamChat(
|
||||
tokenUsage = agentllm.MergeUsage(tokenUsage, chunk.ResponseMeta.Usage)
|
||||
}
|
||||
|
||||
fullText.WriteString(chunk.Content)
|
||||
if chunk != nil {
|
||||
if strings.TrimSpace(chunk.ReasoningContent) != "" && localReasoningStartAt == nil {
|
||||
now := time.Now()
|
||||
localReasoningStartAt = &now
|
||||
}
|
||||
if strings.TrimSpace(chunk.Content) != "" && localReasoningStartAt != nil && reasoningEndAt == nil {
|
||||
now := time.Now()
|
||||
reasoningEndAt = &now
|
||||
}
|
||||
fullText.WriteString(chunk.Content)
|
||||
reasoningText.WriteString(chunk.ReasoningContent)
|
||||
}
|
||||
|
||||
payload, err := agentstream.ToOpenAIStream(chunk, requestID, modelName, created, firstChunk)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
if payload != "" {
|
||||
outChan <- payload
|
||||
@@ -112,7 +131,7 @@ func StreamChat(
|
||||
|
||||
finishChunk, err := agentstream.ToOpenAIFinishStream(requestID, modelName, created)
|
||||
if err != nil {
|
||||
return "", nil, err
|
||||
return "", "", 0, nil, err
|
||||
}
|
||||
outChan <- finishChunk
|
||||
outChan <- "[DONE]"
|
||||
@@ -127,5 +146,16 @@ func StreamChat(
|
||||
time.Since(requestStart).Milliseconds(),
|
||||
)*/
|
||||
|
||||
return fullText.String(), tokenUsage, nil
|
||||
reasoningDurationSeconds := 0
|
||||
if localReasoningStartAt != nil {
|
||||
if reasoningEndAt == nil {
|
||||
now := time.Now()
|
||||
reasoningEndAt = &now
|
||||
}
|
||||
if reasoningEndAt.After(*localReasoningStartAt) {
|
||||
reasoningDurationSeconds = int(reasoningEndAt.Sub(*localReasoningStartAt) / time.Second)
|
||||
}
|
||||
}
|
||||
|
||||
return fullText.String(), reasoningText.String(), reasoningDurationSeconds, tokenUsage, nil
|
||||
}
|
||||
|
||||
@@ -10,7 +10,7 @@ func ToEinoMessages(dbMsgs []model.ChatHistory) []*schema.Message {
|
||||
res := make([]*schema.Message, 0)
|
||||
for _, m := range dbMsgs {
|
||||
var role schema.RoleType
|
||||
switch *m.Role {
|
||||
switch safeChatHistoryRole(m.Role) {
|
||||
case "user":
|
||||
role = schema.User
|
||||
case "assistant":
|
||||
@@ -18,10 +18,46 @@ func ToEinoMessages(dbMsgs []model.ChatHistory) []*schema.Message {
|
||||
default:
|
||||
role = schema.System
|
||||
}
|
||||
res = append(res, &schema.Message{
|
||||
Role: role,
|
||||
Content: *m.MessageContent,
|
||||
})
|
||||
msg := &schema.Message{
|
||||
Role: role,
|
||||
Content: safeChatHistoryText(m.MessageContent),
|
||||
ReasoningContent: safeChatHistoryText(m.ReasoningContent),
|
||||
}
|
||||
extra := make(map[string]any)
|
||||
extra["history_id"] = m.ID
|
||||
if m.ReasoningDurationSeconds > 0 {
|
||||
extra["reasoning_duration_seconds"] = m.ReasoningDurationSeconds
|
||||
}
|
||||
if m.RetryGroupID != nil && *m.RetryGroupID != "" {
|
||||
extra["retry_group_id"] = *m.RetryGroupID
|
||||
}
|
||||
if m.RetryIndex != nil && *m.RetryIndex > 0 {
|
||||
extra["retry_index"] = *m.RetryIndex
|
||||
}
|
||||
if m.RetryFromUserMessageID != nil && *m.RetryFromUserMessageID > 0 {
|
||||
extra["retry_from_user_message_id"] = *m.RetryFromUserMessageID
|
||||
}
|
||||
if m.RetryFromAssistantMessageID != nil && *m.RetryFromAssistantMessageID > 0 {
|
||||
extra["retry_from_assistant_message_id"] = *m.RetryFromAssistantMessageID
|
||||
}
|
||||
if len(extra) > 0 {
|
||||
msg.Extra = extra
|
||||
}
|
||||
res = append(res, msg)
|
||||
}
|
||||
return res
|
||||
}
|
||||
|
||||
func safeChatHistoryRole(role *string) string {
|
||||
if role == nil {
|
||||
return ""
|
||||
}
|
||||
return *role
|
||||
}
|
||||
|
||||
func safeChatHistoryText(text *string) string {
|
||||
if text == nil {
|
||||
return ""
|
||||
}
|
||||
return *text
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@ import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/cloudwego/eino/schema"
|
||||
@@ -163,6 +164,79 @@ func (m *AgentCache) BackfillHistory(ctx context.Context, sessionID string, mess
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *AgentCache) ApplyRetrySeed(ctx context.Context, sessionID, retryGroupID string, sourceUserMessageID, sourceAssistantMessageID int) error {
|
||||
if m == nil || m.client == nil {
|
||||
return nil
|
||||
}
|
||||
groupID := strings.TrimSpace(retryGroupID)
|
||||
if groupID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
vals, err := m.client.LRange(ctx, m.historyKey(sessionID), 0, -1).Result()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(vals) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
changed := false
|
||||
targets := map[int]struct{}{}
|
||||
if sourceUserMessageID > 0 {
|
||||
targets[sourceUserMessageID] = struct{}{}
|
||||
}
|
||||
if sourceAssistantMessageID > 0 {
|
||||
targets[sourceAssistantMessageID] = struct{}{}
|
||||
}
|
||||
if len(targets) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
indexOne := 1
|
||||
for idx, raw := range vals {
|
||||
var msg schema.Message
|
||||
if err := json.Unmarshal([]byte(raw), &msg); err != nil {
|
||||
return err
|
||||
}
|
||||
historyID := extractMessageHistoryID(&msg)
|
||||
if historyID <= 0 {
|
||||
continue
|
||||
}
|
||||
if _, ok := targets[historyID]; !ok {
|
||||
continue
|
||||
}
|
||||
if msg.Extra == nil {
|
||||
msg.Extra = make(map[string]any)
|
||||
}
|
||||
msg.Extra["retry_group_id"] = groupID
|
||||
msg.Extra["retry_index"] = indexOne
|
||||
updated, err := json.Marshal(&msg)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
vals[idx] = string(updated)
|
||||
changed = true
|
||||
}
|
||||
|
||||
if !changed {
|
||||
return nil
|
||||
}
|
||||
|
||||
pipe := m.client.Pipeline()
|
||||
key := m.historyKey(sessionID)
|
||||
pipe.Del(ctx, key)
|
||||
values := make([]interface{}, 0, len(vals))
|
||||
for _, item := range vals {
|
||||
values = append(values, item)
|
||||
}
|
||||
pipe.RPush(ctx, key, values...)
|
||||
pipe.LTrim(ctx, key, 0, int64(len(vals)-1))
|
||||
pipe.Expire(ctx, key, m.expiration)
|
||||
_, err = pipe.Exec(ctx)
|
||||
return err
|
||||
}
|
||||
|
||||
func (m *AgentCache) ClearHistory(ctx context.Context, sessionID string) error {
|
||||
historyKey := m.historyKey(sessionID)
|
||||
windowKey := m.historyWindowKey(sessionID)
|
||||
@@ -188,3 +262,46 @@ func (m *AgentCache) DeleteConversationStatus(ctx context.Context, sessionID str
|
||||
key := fmt.Sprintf("smartflow:conversation_status:%s", sessionID)
|
||||
return m.client.Del(ctx, key).Err()
|
||||
}
|
||||
|
||||
func extractMessageHistoryID(msg *schema.Message) int {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
raw, ok := msg.Extra["history_id"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
// 1. history_id 主要来自 DB 回填,正常情况下是 number。
|
||||
// 2. 但 Redis 往返、灰度期数据修复或手工写入时,仍可能出现字符串数字。
|
||||
// 3. 这里做一次宽松解析,避免重试分组补种时因为类型差异找不到源消息。
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
case json.Number:
|
||||
if parsed, err := v.Int64(); err == nil {
|
||||
return int(parsed)
|
||||
}
|
||||
if parsed, err := v.Float64(); err == nil {
|
||||
return int(parsed)
|
||||
}
|
||||
return 0
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return 0
|
||||
}
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0
|
||||
}
|
||||
return parsed
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
@@ -23,38 +23,52 @@ func (r *AgentDAO) WithTx(tx *gorm.DB) *AgentDAO {
|
||||
return &AgentDAO{db: tx}
|
||||
}
|
||||
|
||||
// saveChatHistoryCore 是“聊天消息落库 + 会话统计更新”的核心实现。
|
||||
// saveChatHistoryCore 鏄€滆亰澶╂秷鎭惤搴?+ 浼氳瘽缁熻鏇存柊鈥濈殑鏍稿績瀹炵幇銆?
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只执行当前 DAO 句柄上的数据库写入动作;
|
||||
// 2. 不主动开启事务(事务由调用方决定);
|
||||
// 3. 保证 chat_histories 与 agent_chats.message_count 的一致性口径。
|
||||
// 鑱岃矗杈圭晫锛?
|
||||
// 1. 鍙墽琛屽綋鍓?DAO 鍙ユ焺涓婄殑鏁版嵁搴撳啓鍏ュ姩浣滐紱
|
||||
// 2. 涓嶄富鍔ㄥ紑鍚簨鍔★紙浜嬪姟鐢辫皟鐢ㄦ柟鍐冲畾锛夛紱
|
||||
// 3. 淇濊瘉 chat_histories 涓?agent_chats.message_count 鐨勪竴鑷存€у彛寰勩€?
|
||||
//
|
||||
// 失败处理:
|
||||
// 1. 任一步骤失败都返回 error;
|
||||
// 2. 若调用方处于事务中,返回 error 会触发事务回滚。
|
||||
func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message string, tokensConsumed int) error {
|
||||
// 0. token 入库前兜底:负数统一归零,避免异常值污染累计统计。
|
||||
// 澶辫触澶勭悊锛?
|
||||
// 1. 浠讳竴姝ラ澶辫触閮借繑鍥?error锛?
|
||||
// 2. 鑻ヨ皟鐢ㄦ柟澶勪簬浜嬪姟涓紝杩斿洖 error 浼氳Е鍙戜簨鍔″洖婊氥€?
|
||||
func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, retryGroupID *string, retryIndex *int, retryFromUserMessageID *int, retryFromAssistantMessageID *int, tokensConsumed int) error {
|
||||
// 0. token 鍏ュ簱鍓嶅厹搴曪細璐熸暟缁熶竴褰掗浂锛岄伩鍏嶅紓甯稿€兼薄鏌撶疮璁$粺璁°€?
|
||||
if tokensConsumed < 0 {
|
||||
tokensConsumed = 0
|
||||
}
|
||||
reasoningContent = strings.TrimSpace(reasoningContent)
|
||||
if reasoningDurationSeconds < 0 {
|
||||
reasoningDurationSeconds = 0
|
||||
}
|
||||
|
||||
// 1. 先写 chat_histories 原始消息。
|
||||
// 1. 鍏堝啓 chat_histories 鍘熷娑堟伅銆?
|
||||
var reasoningContentPtr *string
|
||||
if reasoningContent != "" {
|
||||
reasoningContentPtr = &reasoningContent
|
||||
}
|
||||
userChat := model.ChatHistory{
|
||||
UserID: userID,
|
||||
MessageContent: &message,
|
||||
Role: &role,
|
||||
ChatID: conversationID,
|
||||
TokensConsumed: tokensConsumed,
|
||||
UserID: userID,
|
||||
MessageContent: &message,
|
||||
ReasoningContent: reasoningContentPtr,
|
||||
ReasoningDurationSeconds: reasoningDurationSeconds,
|
||||
RetryGroupID: retryGroupID,
|
||||
RetryIndex: retryIndex,
|
||||
RetryFromUserMessageID: retryFromUserMessageID,
|
||||
RetryFromAssistantMessageID: retryFromAssistantMessageID,
|
||||
Role: &role,
|
||||
ChatID: conversationID,
|
||||
TokensConsumed: tokensConsumed,
|
||||
}
|
||||
if err := a.db.WithContext(ctx).Create(&userChat).Error; err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// 2. 再更新会话统计:
|
||||
// 2.1 message_count +1,保持和 chat_histories 行数口径一致;
|
||||
// 2.2 tokens_total 累加本条消息 token;
|
||||
// 2.3 last_message_at 刷新为当前时间,供会话排序使用。
|
||||
// 2. 鍐嶆洿鏂颁細璇濈粺璁★細
|
||||
// 2.1 message_count +1锛屼繚鎸佸拰 chat_histories 琛屾暟鍙e緞涓€鑷达紱
|
||||
// 2.2 tokens_total 绱姞鏈潯娑堟伅 token锛?
|
||||
// 2.3 last_message_at 鍒锋柊涓哄綋鍓嶆椂闂达紝渚涗細璇濇帓搴忎娇鐢ㄣ€?
|
||||
now := time.Now()
|
||||
updates := map[string]interface{}{
|
||||
"message_count": gorm.Expr("message_count + ?", 1),
|
||||
@@ -68,14 +82,14 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
return result.Error
|
||||
}
|
||||
if result.RowsAffected == 0 {
|
||||
// 会话不存在时直接失败,避免出现“孤儿历史消息”。
|
||||
// 浼氳瘽涓嶅瓨鍦ㄦ椂鐩存帴澶辫触锛岄伩鍏嶅嚭鐜扳€滃鍎垮巻鍙叉秷鎭€濄€?
|
||||
return fmt.Errorf("conversation not found when updating stats: user_id=%d chat_id=%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// 3. 最后更新 users.token_usage(同一事务内):
|
||||
// 3.1 只在 tokensConsumed>0 时执行,避免无意义写入;
|
||||
// 3.2 和 chat_histories/agent_chats 放在同一事务里,保证统计口径原子一致;
|
||||
// 3.3 若用户行不存在则返回错误,触发事务回滚,防止出现“会话统计成功但用户统计丢失”。
|
||||
// 3. 鏈€鍚庢洿鏂?users.token_usage锛堝悓涓€浜嬪姟鍐咃級锛?
|
||||
// 3.1 鍙湪 tokensConsumed>0 鏃舵墽琛岋紝閬垮厤鏃犳剰涔夊啓鍏ワ紱
|
||||
// 3.2 鍜?chat_histories/agent_chats 鏀惧湪鍚屼竴浜嬪姟閲岋紝淇濊瘉缁熻鍙e緞鍘熷瓙涓€鑷达紱
|
||||
// 3.3 鑻ョ敤鎴疯涓嶅瓨鍦ㄥ垯杩斿洖閿欒锛岃Е鍙戜簨鍔″洖婊氾紝闃叉鍑虹幇鈥滀細璇濈粺璁℃垚鍔熶絾鐢ㄦ埛缁熻涓㈠け鈥濄€?
|
||||
if tokensConsumed > 0 {
|
||||
userUpdate := a.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
@@ -91,38 +105,38 @@ func (a *AgentDAO) saveChatHistoryCore(ctx context.Context, userID int, conversa
|
||||
return nil
|
||||
}
|
||||
|
||||
// SaveChatHistoryInTx 在调用方“已开启事务”的场景下写入聊天历史。
|
||||
// SaveChatHistoryInTx 鍦ㄨ皟鐢ㄦ柟鈥滃凡寮€鍚簨鍔♀€濈殑鍦烘櫙涓嬪啓鍏ヨ亰澶╁巻鍙层€?
|
||||
//
|
||||
// 设计目的:
|
||||
// 1. 给服务层组合多个 DAO 操作时复用,避免嵌套事务;
|
||||
// 2. 让 outbox 消费处理器可以和业务写入共享同一个 tx。
|
||||
func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message string, tokensConsumed int) error {
|
||||
return a.saveChatHistoryCore(ctx, userID, conversationID, role, message, tokensConsumed)
|
||||
// 璁捐鐩殑锛?
|
||||
// 1. 缁欐湇鍔″眰缁勫悎澶氫釜 DAO 鎿嶄綔鏃跺鐢紝閬垮厤宓屽浜嬪姟锛?
|
||||
// 2. 璁?outbox 娑堣垂澶勭悊鍣ㄥ彲浠ュ拰涓氬姟鍐欏叆鍏变韩鍚屼竴涓?tx銆?
|
||||
func (a *AgentDAO) SaveChatHistoryInTx(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, retryGroupID *string, retryIndex *int, retryFromUserMessageID *int, retryFromAssistantMessageID *int, tokensConsumed int) error {
|
||||
return a.saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, retryGroupID, retryIndex, retryFromUserMessageID, retryFromAssistantMessageID, tokensConsumed)
|
||||
}
|
||||
|
||||
// SaveChatHistory 在同步直写路径下写入聊天历史。
|
||||
// SaveChatHistory 鍦ㄥ悓姝ョ洿鍐欒矾寰勪笅鍐欏叆鑱婂ぉ鍘嗗彶銆?
|
||||
//
|
||||
// 说明:
|
||||
// 1. 该方法会自行开启事务;
|
||||
// 2. 内部复用 saveChatHistoryCore,确保和 SaveChatHistoryInTx 的业务口径完全一致。
|
||||
func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message string, tokensConsumed int) error {
|
||||
// 璇存槑锛?
|
||||
// 1. 璇ユ柟娉曚細鑷寮€鍚簨鍔★紱
|
||||
// 2. 鍐呴儴澶嶇敤 saveChatHistoryCore锛岀‘淇濆拰 SaveChatHistoryInTx 鐨勪笟鍔″彛寰勫畬鍏ㄤ竴鑷淬€?
|
||||
func (a *AgentDAO) SaveChatHistory(ctx context.Context, userID int, conversationID string, role, message, reasoningContent string, reasoningDurationSeconds int, retryGroupID *string, retryIndex *int, retryFromUserMessageID *int, retryFromAssistantMessageID *int, tokensConsumed int) error {
|
||||
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message, tokensConsumed)
|
||||
return a.WithTx(tx).saveChatHistoryCore(ctx, userID, conversationID, role, message, reasoningContent, reasoningDurationSeconds, retryGroupID, retryIndex, retryFromUserMessageID, retryFromAssistantMessageID, tokensConsumed)
|
||||
})
|
||||
}
|
||||
|
||||
// adjustTokenUsageCore 在同一事务语义下做“会话+用户”token 账本增量调整。
|
||||
// adjustTokenUsageCore 鍦ㄥ悓涓€浜嬪姟璇箟涓嬪仛鈥滀細璇?鐢ㄦ埛鈥漷oken 璐︽湰澧為噺璋冩暣銆?
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只更新 agent_chats.tokens_total 与 users.token_usage;
|
||||
// 2. 不写 chat_histories(消息落库由 SaveChatHistory* 路径负责);
|
||||
// 3. deltaTokens<=0 时视为无操作,直接返回。
|
||||
// 鑱岃矗杈圭晫锛?
|
||||
// 1. 鍙洿鏂?agent_chats.tokens_total 涓?users.token_usage锛?
|
||||
// 2. 涓嶅啓 chat_histories锛堟秷鎭惤搴撶敱 SaveChatHistory* 璺緞璐熻矗锛夛紱
|
||||
// 3. deltaTokens<=0 鏃惰涓烘棤鎿嶄綔锛岀洿鎺ヨ繑鍥炪€?
|
||||
func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, conversationID string, deltaTokens int) error {
|
||||
if deltaTokens <= 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
// 1. 先更新会话累计 token。
|
||||
// 1. 鍏堟洿鏂颁細璇濈疮璁?token銆?
|
||||
chatUpdate := a.db.WithContext(ctx).
|
||||
Model(&model.AgentChat{}).
|
||||
Where("user_id = ? AND chat_id = ?", userID, conversationID).
|
||||
@@ -134,7 +148,7 @@ func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, convers
|
||||
return fmt.Errorf("conversation not found when adjusting tokens: user_id=%d chat_id=%s", userID, conversationID)
|
||||
}
|
||||
|
||||
// 2. 再更新用户累计 token。
|
||||
// 2. 鍐嶆洿鏂扮敤鎴风疮璁?token銆?
|
||||
userUpdate := a.db.WithContext(ctx).
|
||||
Model(&model.User{}).
|
||||
Where("id = ?", userID).
|
||||
@@ -148,12 +162,12 @@ func (a *AgentDAO) adjustTokenUsageCore(ctx context.Context, userID int, convers
|
||||
return nil
|
||||
}
|
||||
|
||||
// AdjustTokenUsageInTx 在调用方已开启事务时执行 token 账本增量调整。
|
||||
// AdjustTokenUsageInTx 鍦ㄨ皟鐢ㄦ柟宸插紑鍚簨鍔℃椂鎵ц token 璐︽湰澧為噺璋冩暣銆?
|
||||
func (a *AgentDAO) AdjustTokenUsageInTx(ctx context.Context, userID int, conversationID string, deltaTokens int) error {
|
||||
return a.adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens)
|
||||
}
|
||||
|
||||
// AdjustTokenUsage 在同步路径下执行 token 账本增量调整(内部自带事务)。
|
||||
// AdjustTokenUsage 鍦ㄥ悓姝ヨ矾寰勪笅鎵ц token 璐︽湰澧為噺璋冩暣锛堝唴閮ㄨ嚜甯︿簨鍔★級銆?
|
||||
func (a *AgentDAO) AdjustTokenUsage(ctx context.Context, userID int, conversationID string, deltaTokens int) error {
|
||||
return a.db.WithContext(ctx).Transaction(func(tx *gorm.DB) error {
|
||||
return a.WithTx(tx).adjustTokenUsageCore(ctx, userID, conversationID, deltaTokens)
|
||||
@@ -183,13 +197,58 @@ func (a *AgentDAO) GetUserChatHistories(ctx context.Context, userID, limit int,
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 保留“最近 N 条”后,反转成时间正序,方便模型消费。
|
||||
// 淇濈暀鈥滄渶杩?N 鏉♀€濆悗锛屽弽杞垚鏃堕棿姝e簭锛屾柟渚挎ā鍨嬫秷璐广€?
|
||||
for i, j := 0, len(histories)-1; i < j; i, j = i+1, j-1 {
|
||||
histories[i], histories[j] = histories[j], histories[i]
|
||||
}
|
||||
return histories, nil
|
||||
}
|
||||
|
||||
func (a *AgentDAO) EnsureRetryGroupSeed(ctx context.Context, userID int, chatID, retryGroupID string, sourceUserMessageID, sourceAssistantMessageID int) error {
|
||||
normalizedGroupID := strings.TrimSpace(retryGroupID)
|
||||
if normalizedGroupID == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
indexOne := 1
|
||||
ids := make([]int, 0, 2)
|
||||
if sourceUserMessageID > 0 {
|
||||
ids = append(ids, sourceUserMessageID)
|
||||
}
|
||||
if sourceAssistantMessageID > 0 {
|
||||
ids = append(ids, sourceAssistantMessageID)
|
||||
}
|
||||
if len(ids) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
return a.db.WithContext(ctx).
|
||||
Model(&model.ChatHistory{}).
|
||||
Where("user_id = ? AND chat_id = ? AND id IN ?", userID, chatID, ids).
|
||||
Where("(retry_group_id IS NULL OR retry_group_id = '')").
|
||||
Updates(map[string]any{
|
||||
"retry_group_id": normalizedGroupID,
|
||||
"retry_index": indexOne,
|
||||
}).Error
|
||||
}
|
||||
|
||||
func (a *AgentDAO) GetRetryGroupNextIndex(ctx context.Context, userID int, chatID, retryGroupID string) (int, error) {
|
||||
normalizedGroupID := strings.TrimSpace(retryGroupID)
|
||||
if normalizedGroupID == "" {
|
||||
return 0, errors.New("retry_group_id is empty")
|
||||
}
|
||||
|
||||
var maxIndex int
|
||||
if err := a.db.WithContext(ctx).
|
||||
Model(&model.ChatHistory{}).
|
||||
Where("user_id = ? AND chat_id = ? AND retry_group_id = ?", userID, chatID, normalizedGroupID).
|
||||
Select("COALESCE(MAX(retry_index), 0)").
|
||||
Scan(&maxIndex).Error; err != nil {
|
||||
return 0, err
|
||||
}
|
||||
return maxIndex + 1, nil
|
||||
}
|
||||
|
||||
func (a *AgentDAO) IfChatExists(ctx context.Context, userID int, chatID string) (bool, error) {
|
||||
var chat model.AgentChat
|
||||
err := a.db.WithContext(ctx).Where("user_id = ? AND chat_id = ?", userID, chatID).First(&chat).Error
|
||||
@@ -202,7 +261,7 @@ func (a *AgentDAO) IfChatExists(ctx context.Context, userID int, chatID string)
|
||||
return true, nil
|
||||
}
|
||||
|
||||
// GetConversationMeta 查询单个会话元信息。
|
||||
// GetConversationMeta 鏌ヨ鍗曚釜浼氳瘽鍏冧俊鎭€?
|
||||
func (a *AgentDAO) GetConversationMeta(ctx context.Context, userID int, chatID string) (*model.AgentChat, error) {
|
||||
var chat model.AgentChat
|
||||
err := a.db.WithContext(ctx).
|
||||
@@ -215,7 +274,7 @@ func (a *AgentDAO) GetConversationMeta(ctx context.Context, userID int, chatID s
|
||||
return &chat, nil
|
||||
}
|
||||
|
||||
// GetConversationTitle 读取当前会话标题。
|
||||
// GetConversationTitle 璇诲彇褰撳墠浼氳瘽鏍囬銆?
|
||||
func (a *AgentDAO) GetConversationTitle(ctx context.Context, userID int, chatID string) (title string, exists bool, err error) {
|
||||
var chat model.AgentChat
|
||||
queryErr := a.db.WithContext(ctx).
|
||||
@@ -234,7 +293,7 @@ func (a *AgentDAO) GetConversationTitle(ctx context.Context, userID int, chatID
|
||||
return strings.TrimSpace(*chat.Title), true, nil
|
||||
}
|
||||
|
||||
// UpdateConversationTitleIfEmpty 仅在标题为空时更新会话标题。
|
||||
// UpdateConversationTitleIfEmpty 浠呭湪鏍囬涓虹┖鏃舵洿鏂颁細璇濇爣棰樸€?
|
||||
func (a *AgentDAO) UpdateConversationTitleIfEmpty(ctx context.Context, userID int, chatID, title string) error {
|
||||
normalized := strings.TrimSpace(title)
|
||||
if normalized == "" {
|
||||
@@ -246,20 +305,20 @@ func (a *AgentDAO) UpdateConversationTitleIfEmpty(ctx context.Context, userID in
|
||||
Update("title", normalized).Error
|
||||
}
|
||||
|
||||
// GetConversationList 按分页查询指定用户的会话列表。
|
||||
// GetConversationList 鎸夊垎椤垫煡璇㈡寚瀹氱敤鎴风殑浼氳瘽鍒楄〃銆?
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只负责读库,不负责缓存;
|
||||
// 2. 只负责 user_id 数据隔离,不负责参数合法性兜底(由 service 负责);
|
||||
// 3. 返回总数 total 供上层计算 has_more。
|
||||
// 鑱岃矗杈圭晫锛?
|
||||
// 1. 鍙礋璐h搴擄紝涓嶈礋璐g紦瀛橈紱
|
||||
// 2. 鍙礋璐?user_id 鏁版嵁闅旂锛屼笉璐熻矗鍙傛暟鍚堟硶鎬у厹搴曪紙鐢?service 璐熻矗锛夛紱
|
||||
// 3. 杩斿洖鎬绘暟 total 渚涗笂灞傝绠?has_more銆?
|
||||
func (a *AgentDAO) GetConversationList(ctx context.Context, userID, page, pageSize int, status string) ([]model.AgentChat, int64, error) {
|
||||
// 1. 先构造统一过滤条件,保证 total 与 list 的统计口径一致。
|
||||
// 1. 鍏堟瀯閫犵粺涓€杩囨护鏉′欢锛屼繚璇?total 涓?list 鐨勭粺璁″彛寰勪竴鑷淬€?
|
||||
baseQuery := a.db.WithContext(ctx).Model(&model.AgentChat{}).Where("user_id = ?", userID)
|
||||
if strings.TrimSpace(status) != "" {
|
||||
baseQuery = baseQuery.Where("status = ?", status)
|
||||
}
|
||||
|
||||
// 2. 先查总条数,给前端分页器提供完整元信息。
|
||||
// 2. 鍏堟煡鎬绘潯鏁帮紝缁欏墠绔垎椤靛櫒鎻愪緵瀹屾暣鍏冧俊鎭€?
|
||||
var total int64
|
||||
if err := baseQuery.Count(&total).Error; err != nil {
|
||||
return nil, 0, err
|
||||
@@ -268,9 +327,9 @@ func (a *AgentDAO) GetConversationList(ctx context.Context, userID, page, pageSi
|
||||
return make([]model.AgentChat, 0), 0, nil
|
||||
}
|
||||
|
||||
// 3. 再查当前页数据:
|
||||
// 3.1 按最近消息时间倒序,保证“最近活跃”优先展示;
|
||||
// 3.2 同时间戳下按 id 倒序,避免翻页时顺序抖动。
|
||||
// 3. 鍐嶆煡褰撳墠椤垫暟鎹細
|
||||
// 3.1 鎸夋渶杩戞秷鎭椂闂村€掑簭锛屼繚璇佲€滄渶杩戞椿璺冣€濅紭鍏堝睍绀猴紱
|
||||
// 3.2 鍚屾椂闂存埑涓嬫寜 id 鍊掑簭锛岄伩鍏嶇炕椤垫椂椤哄簭鎶栧姩銆?
|
||||
offset := (page - 1) * pageSize
|
||||
var chats []model.AgentChat
|
||||
query := a.db.WithContext(ctx).
|
||||
|
||||
@@ -7,29 +7,23 @@ type UserSendMessageRequest struct {
|
||||
Message string `json:"message" binding:"required"`
|
||||
Model string `json:"model,omitempty"`
|
||||
Thinking bool `json:"thinking,omitempty"`
|
||||
Extra map[string]any `json:"extra,omitempty"` // 附加参数(如 task_class_id),供 agent 分支链路使用
|
||||
Extra map[string]any `json:"extra,omitempty"`
|
||||
}
|
||||
|
||||
// ChatHistoryPersistPayload 是“聊天消息持久化请求”业务 DTO。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只描述聊天业务需要落库的核心字段;
|
||||
// 2. 可被同步直写路径与异步事件路径复用;
|
||||
// 3. 不包含 outbox/kafka 协议字段(这些字段由 infra 层统一封装)。
|
||||
type ChatHistoryPersistPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Role string `json:"role"`
|
||||
Message string `json:"message"`
|
||||
TokensConsumed int `json:"tokens_consumed"`
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Role string `json:"role"`
|
||||
Message string `json:"message"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ReasoningDurationSeconds int `json:"reasoning_duration_seconds,omitempty"`
|
||||
RetryGroupID *string `json:"retry_group_id,omitempty"`
|
||||
RetryIndex *int `json:"retry_index,omitempty"`
|
||||
RetryFromUserMessageID *int `json:"retry_from_user_message_id,omitempty"`
|
||||
RetryFromAssistantMessageID *int `json:"retry_from_assistant_message_id,omitempty"`
|
||||
TokensConsumed int `json:"tokens_consumed"`
|
||||
}
|
||||
|
||||
// ChatTokenUsageAdjustPayload 是“会话 token 账本增量调整”事件载荷。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只表达“对哪个用户/会话增加多少 token”;
|
||||
// 2. 不承载 chat_histories 落库语义(消息正文由聊天持久化事件负责);
|
||||
// 3. 不包含 outbox/kafka 协议字段(由基础设施层统一封装)。
|
||||
type ChatTokenUsageAdjustPayload struct {
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
@@ -38,11 +32,6 @@ type ChatTokenUsageAdjustPayload struct {
|
||||
TriggeredAt time.Time `json:"triggered_at"`
|
||||
}
|
||||
|
||||
// GetConversationMetaResponse 是会话元信息查询接口的返回结构。
|
||||
// 说明:
|
||||
// 1) title 可能为空字符串(表示标题尚未生成);
|
||||
// 2) has_title 便于前端快速判断是否需要展示默认占位文案;
|
||||
// 3) 保留 message_count/last_message_at,方便前端后续扩展会话列表排序或角标。
|
||||
type GetConversationMetaResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Title string `json:"title"`
|
||||
@@ -52,12 +41,6 @@ type GetConversationMetaResponse struct {
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
// GetConversationListItem 是“会话列表”中的单项数据。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 仅承载列表展示所需的轻量字段,不承载具体消息正文;
|
||||
// 2. title 允许为空字符串,has_title 用于前端快速判断占位文案;
|
||||
// 3. message_count/last_message_at 用于排序展示与角标扩展。
|
||||
type GetConversationListItem struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
Title string `json:"title"`
|
||||
@@ -68,12 +51,6 @@ type GetConversationListItem struct {
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
}
|
||||
|
||||
// GetConversationListResponse 是“获取用户会话列表”接口的统一响应体。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. list 承载当前页数据;
|
||||
// 2. page/page_size/total/has_more 承载分页语义;
|
||||
// 3. 不负责返回会话正文明细(正文仍由聊天历史接口承担)。
|
||||
type GetConversationListResponse struct {
|
||||
List []GetConversationListItem `json:"list"`
|
||||
Page int `json:"page"`
|
||||
@@ -83,58 +60,30 @@ type GetConversationListResponse struct {
|
||||
HasMore bool `json:"has_more"`
|
||||
}
|
||||
|
||||
// GetConversationHistoryItem 是“按会话读取聊天历史”接口的单条消息响应。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. role/content:承载前端渲染消息气泡所需的核心字段;
|
||||
// 2. id/created_at:仅在回源 DB 时可稳定提供,命中 Redis 时允许为空;
|
||||
// 3. reasoning_content:兼容模型推理内容,缓存命中时可直接透传。
|
||||
type GetConversationHistoryItem struct {
|
||||
ID int `json:"id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ID int `json:"id,omitempty"`
|
||||
Role string `json:"role"`
|
||||
Content string `json:"content"`
|
||||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||||
ReasoningDurationSeconds int `json:"reasoning_duration_seconds,omitempty"`
|
||||
RetryGroupID *string `json:"retry_group_id"`
|
||||
RetryIndex *int `json:"retry_index"`
|
||||
RetryTotal *int `json:"retry_total"`
|
||||
}
|
||||
|
||||
// SchedulePlanPreviewCache 是“排程预览”在 Redis 中的缓存结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责承载排程完成后的结构化预览快照(summary + candidate_plans);
|
||||
// 2. 通过 user_id 做查询归属校验,避免跨用户越权读取;
|
||||
// 3. 仅用于缓存层读写,不表示已落库或已应用到正式日程;
|
||||
// 4. 通过 trace_id 标识本次预览来源,便于排查链路问题。
|
||||
type SchedulePlanPreviewCache struct {
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
|
||||
// TaskClassIDs 记录本次预览对应的任务类集合。
|
||||
// 作用:
|
||||
// 1. 连续对话微调时,若本轮请求未显式传 task_class_ids,可用该字段兜底;
|
||||
// 2. 仅用于会话内上下文承接,不表示用户最终确认后的持久化状态。
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
// HybridEntries 保存“可优化的混合日程底板”。
|
||||
// 作用:
|
||||
// 1. 连续对话微调时复用上轮结果作为起点,避免每轮都从粗排重算;
|
||||
// 2. 仅缓存态,生命周期受 Redis TTL 控制。
|
||||
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
|
||||
// AllocatedItems 保存建议任务块的当前分配状态。
|
||||
// 作用:
|
||||
// 1. 保证 final_check 的数量核对口径在连续微调场景下可持续;
|
||||
// 2. return_preview 节点可继续回填 embedded_time。
|
||||
AllocatedItems []TaskClassItem `json:"allocated_items,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
UserID int `json:"user_id"`
|
||||
ConversationID string `json:"conversation_id"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
Summary string `json:"summary"`
|
||||
CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
|
||||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||||
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
|
||||
AllocatedItems []TaskClassItem `json:"allocated_items,omitempty"`
|
||||
GeneratedAt time.Time `json:"generated_at"`
|
||||
}
|
||||
|
||||
// GetSchedulePlanPreviewResponse 是“按会话查询排程预览”接口返回结构。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. conversation_id:标识该预览属于哪个会话;
|
||||
// 2. summary:给用户展示的终审自然语言总结;
|
||||
// 3. candidate_plans:给前端渲染课表/时间轴用的结构化 JSON;
|
||||
// 4. generated_at:预览生成时间,便于前端判断是否是最新结果。
|
||||
type GetSchedulePlanPreviewResponse struct {
|
||||
ConversationID string `json:"conversation_id"`
|
||||
TraceID string `json:"trace_id,omitempty"`
|
||||
@@ -174,15 +123,20 @@ type AgentChat struct {
|
||||
func (AgentChat) TableName() string { return "agent_chats" }
|
||||
|
||||
type ChatHistory struct {
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;index:idx_user_chat,priority:2;index:idx_chat_id;comment:会话UUID"`
|
||||
UserID int `gorm:"column:user_id;not null;index:idx_user_chat,priority:1"`
|
||||
MessageContent *string `gorm:"column:message_content;type:text;comment:消息内容"`
|
||||
Role *string `gorm:"column:role;type:varchar(32);comment:消息角色"`
|
||||
TokensConsumed int `gorm:"column:tokens_consumed;not null;default:0;comment:本轮消耗Token"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||||
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;index:idx_user_chat,priority:2;index:idx_chat_id;comment:会话UUID"`
|
||||
UserID int `gorm:"column:user_id;not null;index:idx_user_chat,priority:1"`
|
||||
MessageContent *string `gorm:"column:message_content;type:text;comment:消息内容"`
|
||||
ReasoningContent *string `gorm:"column:reasoning_content;type:text;comment:deep reasoning text"`
|
||||
ReasoningDurationSeconds int `gorm:"column:reasoning_duration_seconds;not null;default:0;comment:deep reasoning duration seconds"`
|
||||
RetryGroupID *string `gorm:"column:retry_group_id;type:varchar(64);index:idx_retry_group;comment:retry group id"`
|
||||
RetryIndex *int `gorm:"column:retry_index;comment:retry page index"`
|
||||
RetryFromUserMessageID *int `gorm:"column:retry_from_user_message_id;comment:source user message id"`
|
||||
RetryFromAssistantMessageID *int `gorm:"column:retry_from_assistant_message_id;comment:source assistant message id"`
|
||||
Role *string `gorm:"column:role;type:varchar(32);comment:消息角色"`
|
||||
TokensConsumed int `gorm:"column:tokens_consumed;not null;default:0;comment:本轮消耗Token"`
|
||||
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"`
|
||||
|
||||
// 只保留从聊天记录到会话的单向关联,避免迁移时出现循环依赖。
|
||||
Chat AgentChat `gorm:"foreignKey:ChatID;references:ChatID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||||
}
|
||||
|
||||
|
||||
@@ -2,7 +2,9 @@ package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"log"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
@@ -101,7 +103,20 @@ func (s *AgentService) PersistChatHistory(ctx context.Context, payload model.Cha
|
||||
// 1. 未注入事件发布器时(例如本地极简环境),直接同步写 DB。
|
||||
// 这样可以保证功能不依赖 Kafka 也能跑通。
|
||||
if s.eventPublisher == nil {
|
||||
return s.repo.SaveChatHistory(ctx, payload.UserID, payload.ConversationID, payload.Role, payload.Message, payload.TokensConsumed)
|
||||
return s.repo.SaveChatHistory(
|
||||
ctx,
|
||||
payload.UserID,
|
||||
payload.ConversationID,
|
||||
payload.Role,
|
||||
payload.Message,
|
||||
payload.ReasoningContent,
|
||||
payload.ReasoningDurationSeconds,
|
||||
payload.RetryGroupID,
|
||||
payload.RetryIndex,
|
||||
payload.RetryFromUserMessageID,
|
||||
payload.RetryFromAssistantMessageID,
|
||||
payload.TokensConsumed,
|
||||
)
|
||||
}
|
||||
// 2. 已启用异步总线时,只发布“持久化请求事件”,不在请求路径阻塞 Kafka。
|
||||
// 2.1 发布成功仅代表“事件安全入队”,实际落库由消费者异步完成。
|
||||
@@ -114,6 +129,185 @@ func (s *AgentService) saveChatHistoryReliable(ctx context.Context, payload mode
|
||||
return s.PersistChatHistory(ctx, payload)
|
||||
}
|
||||
|
||||
func mergeAgentReasoningText(parts ...string) string {
|
||||
merged := make([]string, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
text := strings.TrimSpace(part)
|
||||
if text == "" {
|
||||
continue
|
||||
}
|
||||
merged = append(merged, text)
|
||||
}
|
||||
return strings.Join(merged, "\n\n")
|
||||
}
|
||||
|
||||
type chatRetryMeta struct {
|
||||
GroupID string
|
||||
Index int
|
||||
FromUserMessageID int
|
||||
FromAssistantMessageID int
|
||||
}
|
||||
|
||||
func (m *chatRetryMeta) GroupIDPtr() *string {
|
||||
if m == nil || strings.TrimSpace(m.GroupID) == "" {
|
||||
return nil
|
||||
}
|
||||
groupID := strings.TrimSpace(m.GroupID)
|
||||
return &groupID
|
||||
}
|
||||
|
||||
func (m *chatRetryMeta) IndexPtr() *int {
|
||||
if m == nil || m.Index <= 0 {
|
||||
return nil
|
||||
}
|
||||
index := m.Index
|
||||
return &index
|
||||
}
|
||||
|
||||
func (m *chatRetryMeta) FromUserMessageIDPtr() *int {
|
||||
if m == nil || m.FromUserMessageID <= 0 {
|
||||
return nil
|
||||
}
|
||||
id := m.FromUserMessageID
|
||||
return &id
|
||||
}
|
||||
|
||||
func (m *chatRetryMeta) FromAssistantMessageIDPtr() *int {
|
||||
if m == nil || m.FromAssistantMessageID <= 0 {
|
||||
return nil
|
||||
}
|
||||
id := m.FromAssistantMessageID
|
||||
return &id
|
||||
}
|
||||
|
||||
func (m *chatRetryMeta) CacheExtra() map[string]any {
|
||||
if m == nil || strings.TrimSpace(m.GroupID) == "" || m.Index <= 0 {
|
||||
return nil
|
||||
}
|
||||
extra := map[string]any{
|
||||
"retry_group_id": m.GroupID,
|
||||
"retry_index": m.Index,
|
||||
}
|
||||
if m.FromUserMessageID > 0 {
|
||||
extra["retry_from_user_message_id"] = m.FromUserMessageID
|
||||
}
|
||||
if m.FromAssistantMessageID > 0 {
|
||||
extra["retry_from_assistant_message_id"] = m.FromAssistantMessageID
|
||||
}
|
||||
return extra
|
||||
}
|
||||
|
||||
func (s *AgentService) buildChatRetryMeta(ctx context.Context, userID int, chatID string, extra map[string]any) (*chatRetryMeta, error) {
|
||||
if len(extra) == 0 {
|
||||
return nil, nil
|
||||
}
|
||||
requestMode := strings.ToLower(strings.TrimSpace(readAgentExtraString(extra, "request_mode")))
|
||||
if requestMode != "retry" {
|
||||
return nil, nil
|
||||
}
|
||||
|
||||
groupID := strings.TrimSpace(readAgentExtraString(extra, "retry_group_id"))
|
||||
if groupID == "" {
|
||||
groupID = uuid.NewString()
|
||||
}
|
||||
|
||||
sourceUserMessageID := readAgentExtraInt(extra, "retry_from_user_message_id")
|
||||
sourceAssistantMessageID := readAgentExtraInt(extra, "retry_from_assistant_message_id")
|
||||
|
||||
if err := s.repo.EnsureRetryGroupSeed(ctx, userID, chatID, groupID, sourceUserMessageID, sourceAssistantMessageID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if s.agentCache != nil && (sourceUserMessageID > 0 || sourceAssistantMessageID > 0) {
|
||||
if cacheErr := s.agentCache.ApplyRetrySeed(ctx, chatID, groupID, sourceUserMessageID, sourceAssistantMessageID); cacheErr != nil {
|
||||
log.Printf("更新重试分组缓存失败 chat=%s group=%s err=%v", chatID, groupID, cacheErr)
|
||||
}
|
||||
}
|
||||
|
||||
nextIndex, err := s.repo.GetRetryGroupNextIndex(ctx, userID, chatID, groupID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
return &chatRetryMeta{
|
||||
GroupID: groupID,
|
||||
Index: nextIndex,
|
||||
FromUserMessageID: sourceUserMessageID,
|
||||
FromAssistantMessageID: sourceAssistantMessageID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func readAgentExtraString(extra map[string]any, key string) string {
|
||||
if len(extra) == 0 {
|
||||
return ""
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(text)
|
||||
}
|
||||
|
||||
func readAgentExtraInt(extra map[string]any, key string) int {
|
||||
if len(extra) == 0 {
|
||||
return 0
|
||||
}
|
||||
raw, ok := extra[key]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
// 1. 前端的历史消息 id 在本地态里可能是 string,也可能是 number。
|
||||
// 2. 重试链路只要这里解析失败,父消息 id 就会退化成 0,后续写库自然会落成 NULL。
|
||||
// 3. 因此这里统一做“宽松整型解析”,兼容 JSON number、前端字符串数字和常见整数类型。
|
||||
value, ok := parseAgentLooseInt(raw)
|
||||
if !ok || value <= 0 {
|
||||
return 0
|
||||
}
|
||||
return value
|
||||
}
|
||||
|
||||
// parseAgentLooseInt 负责把 extra 中的“弱类型数字”归一成 int。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责兼容前端 JSON 解码后的常见数值类型,以及字符串形式的数字。
|
||||
// 2. 不负责业务语义校验;例如是否必须大于 0,由调用方自行决定。
|
||||
// 3. 解析失败时返回 ok=false,调用方可按各自场景走兜底逻辑。
|
||||
func parseAgentLooseInt(raw any) (value int, ok bool) {
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v, true
|
||||
case int32:
|
||||
return int(v), true
|
||||
case int64:
|
||||
return int(v), true
|
||||
case float64:
|
||||
return int(v), true
|
||||
case json.Number:
|
||||
if parsed, err := v.Int64(); err == nil {
|
||||
return int(parsed), true
|
||||
}
|
||||
if parsed, err := v.Float64(); err == nil {
|
||||
return int(parsed), true
|
||||
}
|
||||
return 0, false
|
||||
case string:
|
||||
trimmed := strings.TrimSpace(v)
|
||||
if trimmed == "" {
|
||||
return 0, false
|
||||
}
|
||||
parsed, err := strconv.Atoi(trimmed)
|
||||
if err != nil {
|
||||
return 0, false
|
||||
}
|
||||
return parsed, true
|
||||
default:
|
||||
return 0, false
|
||||
}
|
||||
}
|
||||
|
||||
// pushErrNonBlocking 向错误通道“尽力投递”错误。
|
||||
// 目的:
|
||||
// 1) 避免 goroutine 在 errChan 满时被阻塞导致泄漏;
|
||||
@@ -135,6 +329,9 @@ func (s *AgentService) runNormalChatFlow(
|
||||
selectedModel *ark.ChatModel,
|
||||
resolvedModelName string,
|
||||
userMessage string,
|
||||
assistantReasoningPrefix string,
|
||||
assistantReasoningStartedAt *time.Time,
|
||||
retryMeta *chatRetryMeta,
|
||||
ifThinking bool,
|
||||
userID int,
|
||||
chatID string,
|
||||
@@ -192,11 +389,12 @@ func (s *AgentService) runNormalChatFlow(
|
||||
|
||||
// 6. 执行真正的流式聊天。
|
||||
// fullText 用于后续写 Redis/持久化,outChan 用于把流片段实时推给前端。
|
||||
fullText, streamUsage, streamErr := agentchat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart)
|
||||
fullText, reasoningText, reasoningDurationSeconds, streamUsage, streamErr := agentchat.StreamChat(ctx, selectedModel, resolvedModelName, userMessage, ifThinking, chatHistory, outChan, traceID, chatID, requestStart, assistantReasoningStartedAt)
|
||||
if streamErr != nil {
|
||||
pushErrNonBlocking(errChan, streamErr)
|
||||
return
|
||||
}
|
||||
assistantReasoning := mergeAgentReasoningText(assistantReasoningPrefix, reasoningText)
|
||||
|
||||
// 6.1 流式 usage 并入请求级 token 统计器:
|
||||
// 6.1.1 route/quicknote/taskquery 等 Generate 调用由 callback 自动累加;
|
||||
@@ -212,15 +410,25 @@ func (s *AgentService) runNormalChatFlow(
|
||||
// 7. 后置持久化(用户消息):
|
||||
// 7.1 先写 Redis,保证“最新会话上下文”可立即用于下一轮推理;
|
||||
// 7.2 再走可靠持久化入口(outbox 或同步 DB)。
|
||||
if err = s.agentCache.PushMessage(ctx, chatID, &schema.Message{Role: schema.User, Content: userMessage}); err != nil {
|
||||
userMsg := &schema.Message{Role: schema.User, Content: userMessage}
|
||||
if retryExtra := retryMeta.CacheExtra(); len(retryExtra) > 0 {
|
||||
userMsg.Extra = retryExtra
|
||||
}
|
||||
if err = s.agentCache.PushMessage(ctx, chatID, userMsg); err != nil {
|
||||
log.Printf("写入用户消息到 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
if err = s.PersistChatHistory(ctx, model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "user",
|
||||
Message: userMessage,
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "user",
|
||||
Message: userMessage,
|
||||
ReasoningContent: "",
|
||||
ReasoningDurationSeconds: 0,
|
||||
RetryGroupID: retryMeta.GroupIDPtr(),
|
||||
RetryIndex: retryMeta.IndexPtr(),
|
||||
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
|
||||
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
|
||||
// 口径B:用户消息固定记 0;本轮总 token 统一记在助手消息。
|
||||
TokensConsumed: 0,
|
||||
}); err != nil {
|
||||
@@ -233,15 +441,33 @@ func (s *AgentService) runNormalChatFlow(
|
||||
// 8. 后置持久化(助手消息):
|
||||
// 8.1 先写 Redis,保证下一轮上下文可见;
|
||||
// 8.2 再异步可靠落库,失败通过 errChan 回传给上层。
|
||||
if err = s.agentCache.PushMessage(context.Background(), chatID, &schema.Message{Role: schema.Assistant, Content: fullText}); err != nil {
|
||||
assistantMsg := &schema.Message{Role: schema.Assistant, Content: fullText, ReasoningContent: assistantReasoning}
|
||||
if reasoningDurationSeconds > 0 {
|
||||
assistantMsg.Extra = map[string]any{"reasoning_duration_seconds": reasoningDurationSeconds}
|
||||
}
|
||||
if retryExtra := retryMeta.CacheExtra(); len(retryExtra) > 0 {
|
||||
if assistantMsg.Extra == nil {
|
||||
assistantMsg.Extra = make(map[string]any, len(retryExtra))
|
||||
}
|
||||
for key, value := range retryExtra {
|
||||
assistantMsg.Extra[key] = value
|
||||
}
|
||||
}
|
||||
if err = s.agentCache.PushMessage(context.Background(), chatID, assistantMsg); err != nil {
|
||||
log.Printf("写入助手消息到 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
if saveErr := s.PersistChatHistory(context.Background(), model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "assistant",
|
||||
Message: fullText,
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "assistant",
|
||||
Message: fullText,
|
||||
ReasoningContent: assistantReasoning,
|
||||
ReasoningDurationSeconds: reasoningDurationSeconds,
|
||||
RetryGroupID: retryMeta.GroupIDPtr(),
|
||||
RetryIndex: retryMeta.IndexPtr(),
|
||||
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
|
||||
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
|
||||
// 口径B:助手消息记录“本轮请求总 token”。
|
||||
TokensConsumed: requestTotalTokens,
|
||||
}); saveErr != nil {
|
||||
@@ -303,6 +529,14 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
}
|
||||
}
|
||||
|
||||
retryMeta, err := s.buildChatRetryMeta(requestCtx, userID, chatID, extra)
|
||||
if err != nil {
|
||||
errChan <- err
|
||||
close(outChan)
|
||||
close(errChan)
|
||||
return outChan, errChan
|
||||
}
|
||||
|
||||
// 3) 统一异步分流:
|
||||
// 3.1 先走“通用控制码路由”决定 action(chat / quick_note_create / task_query);
|
||||
// 3.2 quick_note_create 进入随口记 graph;
|
||||
@@ -322,7 +556,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
|
||||
// 3.2 chat:直接走普通聊天主链路。
|
||||
if routing.Action == agentrouter.ActionChat {
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, "", nil, retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -358,7 +592,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
|
||||
// 3.4.2 对随口记回复执行统一后置持久化(Redis + outbox/DB)。
|
||||
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, quickReply, 0, requestTotalTokens, errChan)
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, quickReply, progress.HistoryText(), progress.DurationSeconds(time.Now()), retryMeta, 0, requestTotalTokens, errChan)
|
||||
// 3.4.3 随口记链路同样异步生成会话标题(仅首次写入)。
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
return
|
||||
@@ -366,7 +600,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
|
||||
// 3.4.4 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。
|
||||
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -377,7 +611,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
// 3.5.1 任务查询失败时回退普通聊天,避免请求直接中断。
|
||||
log.Printf("任务查询 tool-calling 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, queryErr)
|
||||
progress.Emit("task_query.fallback", "任务查询暂不可用,先切回普通对话。")
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -387,7 +621,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
return
|
||||
}
|
||||
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan)
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, progress.HistoryText(), progress.DurationSeconds(time.Now()), retryMeta, 0, requestTotalTokens, errChan)
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
return
|
||||
}
|
||||
@@ -398,7 +632,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
if planErr != nil {
|
||||
log.Printf("智能排程 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, planErr)
|
||||
progress.Emit("schedule_plan.fallback", "智能排程暂不可用,先切回普通对话。")
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
return
|
||||
}
|
||||
|
||||
@@ -407,7 +641,7 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
return
|
||||
}
|
||||
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan)
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, progress.HistoryText(), progress.DurationSeconds(time.Now()), retryMeta, 0, requestTotalTokens, errChan)
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
return
|
||||
}
|
||||
@@ -426,13 +660,13 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, ifThin
|
||||
return
|
||||
}
|
||||
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, 0, requestTotalTokens, errChan)
|
||||
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, progress.HistoryText(), progress.DurationSeconds(time.Now()), retryMeta, 0, requestTotalTokens, errChan)
|
||||
s.ensureConversationTitleAsync(userID, chatID)
|
||||
return
|
||||
}
|
||||
|
||||
// 3.8 未知 action 兜底:走普通聊天,保证可用性。
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
|
||||
}()
|
||||
|
||||
return outChan, errChan
|
||||
|
||||
@@ -45,7 +45,7 @@ func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, c
|
||||
history, cacheErr := s.agentCache.GetHistory(ctx, normalizedChatID)
|
||||
if cacheErr != nil {
|
||||
log.Printf("读取会话历史缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
|
||||
} else if history != nil {
|
||||
} else if history != nil && !cacheConversationHistoryHasRetryMetadata(history) {
|
||||
return buildConversationHistoryItemsFromCache(history), nil
|
||||
}
|
||||
}
|
||||
@@ -81,12 +81,15 @@ func buildConversationHistoryItemsFromCache(messages []*schema.Message) []model.
|
||||
continue
|
||||
}
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
Role: normalizeConversationHistoryRole(string(msg.Role)),
|
||||
Content: strings.TrimSpace(msg.Content),
|
||||
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
|
||||
Role: normalizeConversationHistoryRole(string(msg.Role)),
|
||||
Content: strings.TrimSpace(msg.Content),
|
||||
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
|
||||
ReasoningDurationSeconds: extractConversationReasoningDurationSeconds(msg),
|
||||
RetryGroupID: extractConversationRetryGroupID(msg),
|
||||
RetryIndex: extractConversationRetryIndex(msg),
|
||||
})
|
||||
}
|
||||
return items
|
||||
return attachConversationRetryTotals(items)
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。
|
||||
@@ -109,15 +112,156 @@ func buildConversationHistoryItemsFromDB(histories []model.ChatHistory) []model.
|
||||
}
|
||||
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
ID: history.ID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
CreatedAt: history.CreatedAt,
|
||||
ID: history.ID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
CreatedAt: history.CreatedAt,
|
||||
ReasoningContent: strings.TrimSpace(derefConversationHistoryText(history.ReasoningContent)),
|
||||
ReasoningDurationSeconds: history.ReasoningDurationSeconds,
|
||||
RetryGroupID: cloneConversationStringPointer(history.RetryGroupID),
|
||||
RetryIndex: cloneConversationIntPointer(history.RetryIndex),
|
||||
})
|
||||
}
|
||||
return attachConversationRetryTotals(items)
|
||||
}
|
||||
|
||||
func derefConversationHistoryText(text *string) string {
|
||||
if text == nil {
|
||||
return ""
|
||||
}
|
||||
return *text
|
||||
}
|
||||
|
||||
func extractConversationReasoningDurationSeconds(msg *schema.Message) int {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return 0
|
||||
}
|
||||
raw, ok := msg.Extra["reasoning_duration_seconds"]
|
||||
if !ok {
|
||||
return 0
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
return v
|
||||
case int32:
|
||||
return int(v)
|
||||
case int64:
|
||||
return int(v)
|
||||
case float64:
|
||||
return int(v)
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
func extractConversationRetryGroupID(msg *schema.Message) *string {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := msg.Extra["retry_group_id"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
text, ok := raw.(string)
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
text = strings.TrimSpace(text)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return &text
|
||||
}
|
||||
|
||||
func extractConversationRetryIndex(msg *schema.Message) *int {
|
||||
if msg == nil || msg.Extra == nil {
|
||||
return nil
|
||||
}
|
||||
raw, ok := msg.Extra["retry_index"]
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
switch v := raw.(type) {
|
||||
case int:
|
||||
if v <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &v
|
||||
case int32:
|
||||
value := int(v)
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
case int64:
|
||||
value := int(v)
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
case float64:
|
||||
value := int(v)
|
||||
if value <= 0 {
|
||||
return nil
|
||||
}
|
||||
return &value
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func attachConversationRetryTotals(items []model.GetConversationHistoryItem) []model.GetConversationHistoryItem {
|
||||
if len(items) == 0 {
|
||||
return items
|
||||
}
|
||||
groupTotals := make(map[string]int)
|
||||
for _, item := range items {
|
||||
if item.RetryGroupID == nil || item.RetryIndex == nil {
|
||||
continue
|
||||
}
|
||||
groupID := strings.TrimSpace(*item.RetryGroupID)
|
||||
if groupID == "" {
|
||||
continue
|
||||
}
|
||||
if *item.RetryIndex > groupTotals[groupID] {
|
||||
groupTotals[groupID] = *item.RetryIndex
|
||||
}
|
||||
}
|
||||
for idx := range items {
|
||||
groupIDPtr := items[idx].RetryGroupID
|
||||
if groupIDPtr == nil {
|
||||
continue
|
||||
}
|
||||
groupID := strings.TrimSpace(*groupIDPtr)
|
||||
total := groupTotals[groupID]
|
||||
if total <= 0 {
|
||||
continue
|
||||
}
|
||||
totalCopy := total
|
||||
items[idx].RetryTotal = &totalCopy
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func cloneConversationStringPointer(src *string) *string {
|
||||
if src == nil {
|
||||
return nil
|
||||
}
|
||||
text := strings.TrimSpace(*src)
|
||||
if text == "" {
|
||||
return nil
|
||||
}
|
||||
return &text
|
||||
}
|
||||
|
||||
func cloneConversationIntPointer(src *int) *int {
|
||||
if src == nil || *src <= 0 {
|
||||
return nil
|
||||
}
|
||||
value := *src
|
||||
return &value
|
||||
}
|
||||
|
||||
func normalizeConversationHistoryRole(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "user":
|
||||
@@ -128,3 +272,12 @@ func normalizeConversationHistoryRole(role string) string {
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
|
||||
func cacheConversationHistoryHasRetryMetadata(messages []*schema.Message) bool {
|
||||
for _, msg := range messages {
|
||||
if extractConversationRetryGroupID(msg) != nil {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
@@ -34,6 +34,8 @@ type quickNoteProgressEmitter struct {
|
||||
requestID string
|
||||
created int64
|
||||
enablePush bool
|
||||
reasoning strings.Builder
|
||||
startedAt *time.Time
|
||||
}
|
||||
|
||||
// newQuickNoteProgressEmitter 构造“阶段进度推送器”。
|
||||
@@ -69,6 +71,23 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
|
||||
if stage == "" && detail == "" {
|
||||
return
|
||||
}
|
||||
if e.startedAt == nil {
|
||||
now := time.Now()
|
||||
e.startedAt = &now
|
||||
}
|
||||
if e.reasoning.Len() > 0 {
|
||||
e.reasoning.WriteString("\n\n")
|
||||
}
|
||||
if stage != "" {
|
||||
e.reasoning.WriteString("阶段:")
|
||||
e.reasoning.WriteString(stage)
|
||||
}
|
||||
if detail != "" {
|
||||
if stage != "" {
|
||||
e.reasoning.WriteString("\n")
|
||||
}
|
||||
e.reasoning.WriteString(detail)
|
||||
}
|
||||
|
||||
// 3. 调用目的:阶段提示统一走 agent2/stream 的 reasoning chunk 包装,
|
||||
// 避免 service 层继续自己拼 OpenAI 兼容 JSON。
|
||||
@@ -83,6 +102,31 @@ func (e *quickNoteProgressEmitter) Emit(stage, detail string) {
|
||||
}
|
||||
}
|
||||
|
||||
func (e *quickNoteProgressEmitter) HistoryText() string {
|
||||
if e == nil {
|
||||
return ""
|
||||
}
|
||||
return strings.TrimSpace(e.reasoning.String())
|
||||
}
|
||||
|
||||
func (e *quickNoteProgressEmitter) StartedAt() *time.Time {
|
||||
if e == nil || e.startedAt == nil {
|
||||
return nil
|
||||
}
|
||||
startCopy := *e.startedAt
|
||||
return &startCopy
|
||||
}
|
||||
|
||||
func (e *quickNoteProgressEmitter) DurationSeconds(end time.Time) int {
|
||||
if e == nil || e.startedAt == nil {
|
||||
return 0
|
||||
}
|
||||
if !end.After(*e.startedAt) {
|
||||
return 0
|
||||
}
|
||||
return int(end.Sub(*e.startedAt) / time.Second)
|
||||
}
|
||||
|
||||
// tryHandleQuickNoteWithGraph 尝试用“随口记 graph”处理本次用户输入。
|
||||
// 返回值语义:
|
||||
// 1) handled=true:本次请求已在随口记链路处理完成(成功/失败都会返回文案);
|
||||
@@ -268,39 +312,70 @@ func (s *AgentService) persistChatAfterReply(
|
||||
chatID string,
|
||||
userMessage string,
|
||||
assistantReply string,
|
||||
assistantReasoning string,
|
||||
assistantReasoningDurationSeconds int,
|
||||
retryMeta *chatRetryMeta,
|
||||
userTokens int,
|
||||
assistantTokens int,
|
||||
errChan chan error,
|
||||
) {
|
||||
// 1. 先把用户消息写入 Redis,保证会话上下文“马上可见”。
|
||||
if err := s.agentCache.PushMessage(ctx, chatID, &schema.Message{Role: schema.User, Content: userMessage}); err != nil {
|
||||
userMsg := &schema.Message{Role: schema.User, Content: userMessage}
|
||||
if retryExtra := retryMeta.CacheExtra(); len(retryExtra) > 0 {
|
||||
userMsg.Extra = retryExtra
|
||||
}
|
||||
if err := s.agentCache.PushMessage(ctx, chatID, userMsg); err != nil {
|
||||
log.Printf("写入用户消息到 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
// 2. 再把用户消息写入可靠持久化通道(outbox 或同步 DB)。
|
||||
if err := s.PersistChatHistory(ctx, model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "user",
|
||||
Message: userMessage,
|
||||
TokensConsumed: userTokens,
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "user",
|
||||
Message: userMessage,
|
||||
ReasoningContent: "",
|
||||
ReasoningDurationSeconds: 0,
|
||||
RetryGroupID: retryMeta.GroupIDPtr(),
|
||||
RetryIndex: retryMeta.IndexPtr(),
|
||||
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
|
||||
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
|
||||
TokensConsumed: userTokens,
|
||||
}); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
return
|
||||
}
|
||||
|
||||
// 3. 助手消息同样遵循“Redis 先行 + 可靠持久化补齐”策略。
|
||||
if err := s.agentCache.PushMessage(context.Background(), chatID, &schema.Message{Role: schema.Assistant, Content: assistantReply}); err != nil {
|
||||
assistantMsg := &schema.Message{Role: schema.Assistant, Content: assistantReply, ReasoningContent: assistantReasoning}
|
||||
if assistantReasoningDurationSeconds > 0 {
|
||||
assistantMsg.Extra = map[string]any{"reasoning_duration_seconds": assistantReasoningDurationSeconds}
|
||||
}
|
||||
if retryExtra := retryMeta.CacheExtra(); len(retryExtra) > 0 {
|
||||
if assistantMsg.Extra == nil {
|
||||
assistantMsg.Extra = make(map[string]any, len(retryExtra))
|
||||
}
|
||||
for key, value := range retryExtra {
|
||||
assistantMsg.Extra[key] = value
|
||||
}
|
||||
}
|
||||
if err := s.agentCache.PushMessage(context.Background(), chatID, assistantMsg); err != nil {
|
||||
log.Printf("写入助手消息到 Redis 失败: %v", err)
|
||||
}
|
||||
|
||||
// 4. 助手消息持久化失败不阻断主流程,通过 errChan 异步上报。
|
||||
if err := s.PersistChatHistory(context.Background(), model.ChatHistoryPersistPayload{
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "assistant",
|
||||
Message: assistantReply,
|
||||
TokensConsumed: assistantTokens,
|
||||
UserID: userID,
|
||||
ConversationID: chatID,
|
||||
Role: "assistant",
|
||||
Message: assistantReply,
|
||||
ReasoningContent: assistantReasoning,
|
||||
ReasoningDurationSeconds: assistantReasoningDurationSeconds,
|
||||
RetryGroupID: retryMeta.GroupIDPtr(),
|
||||
RetryIndex: retryMeta.IndexPtr(),
|
||||
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
|
||||
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
|
||||
TokensConsumed: assistantTokens,
|
||||
}); err != nil {
|
||||
pushErrNonBlocking(errChan, err)
|
||||
}
|
||||
|
||||
@@ -68,6 +68,12 @@ func RegisterChatHistoryPersistHandler(
|
||||
payload.ConversationID,
|
||||
payload.Role,
|
||||
payload.Message,
|
||||
payload.ReasoningContent,
|
||||
payload.ReasoningDurationSeconds,
|
||||
payload.RetryGroupID,
|
||||
payload.RetryIndex,
|
||||
payload.RetryFromUserMessageID,
|
||||
payload.RetryFromAssistantMessageID,
|
||||
payload.TokensConsumed,
|
||||
)
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user