Version: 0.8.1.dev.260326

后端:
1.获取agent聊天历史记录接口做了如下更改:
(1)对reasoning_content也做了存储,同步更改了mysql和redis缓存的读写逻辑
(2)为了承接前端的重试/修改消息的逻辑,进行了一些代码和表单上的改动
前端:
1.agent页面新增了很多小组件,改善交互体验
2.新增重试消息/修改消息并重新发送功能,前者有bug,可能前后端都有问题,待修复。
This commit is contained in:
Losita
2026-03-26 22:15:16 +08:00
parent ddf4c09f69
commit ddb0d9cc17
13 changed files with 1828 additions and 322 deletions

View File

@@ -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 先走“通用控制码路由”决定 actionchat / 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

View File

@@ -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
}

View File

@@ -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)
}