Version: 0.9.26.dev.260417

后端:
1. Prompt 层从 execute 专属骨架重构为全节点统一四段式 buildUnifiedStageMessages
  - 新增 unified_context.go:定义 StageMessagesConfig + buildUnifiedStageMessages 统一骨架,所有节点(Chat/Plan/Execute/Deliver/DeepAnswer)共用同一套 msg0~msg3 拼装逻辑
  - 新增 conversation_view.go:通用对话历史渲染 buildConversationHistoryMessage,各节点复用,不再各自维护提取逻辑
  - 新增 chat_context.go / plan_context.go / deliver_context.go:各节点自行渲染 msg1(对话视图)和 msg2(工作区),统一层只负责"怎么拼",不再替节点决定"放什么"
  - Chat/Plan/Deliver/Execute 的 BuildXXXMessages 全部从 buildStageMessages 切到 buildUnifiedStageMessages,移除旧路径
  - 删除 execute_pinned.go:execute 记忆渲染合并到统一层 renderUnifiedMemoryContext
  - Plan prompt 不再在 user prompt 中拼装任务类 ID 列表和 renderStateSummary,改为依赖 msg2 规划工作区;Chat 粗排判断从"上下文有任务类 ID"改为"批量调度需求"
  - Deliver prompt 新增 IsAborted/IsExhaustedTerminal 区分,支持粗排收口和主动终止场景
2. Execute ReAct 上下文简化——移除归档搬运、窗口裁剪和重复工具压缩
  - 移除 splitExecuteLoopRecordsByBoundary、findLatestExecuteBoundaryMarker、tailExecuteLoops、compressExecuteLoopObservationsByTool、buildEarlyExecuteReactSummary、trimExecuteMessage1ByBudget 等六个函数
  - 移除 executeLoopWindowLimit / executeConversationTurnLimit / executeMessage1MaxRunes 等预算常量
  - msg1 不再从历史中归档上一轮 ReAct 结果,只保留真实对话流(user + assistant speak),全量注入
  - msg2 不再按 loop_closed / step_advanced 边界切分"归档/活跃",直接全量注入全部 ReAct Loop 记录
  - token 预算由统一压缩层兜底,prompt 层不再做提前裁剪
3. 压缩层从 Execute 专属提升为全节点通用 UnifiedCompact
  - 删除 execute_compact.go(Execute 专属压缩文件)
  - 新增 unified_compact.go:UnifiedCompactInput 参数化,各节点(Plan/Chat/Deliver/Execute)构造时从自己的 NodeInput 提取公共字段,消除对 Execute 的直接依赖
  - CompactionStore 接口扩展 LoadStageCompaction / SaveStageCompaction,各节点按 stageKey 独立维护压缩状态互不覆盖
  - 非 4 段式消息时退化成按角色汇总统计,确保 context_token_stats 仍然刷新
4. Retry 重试机制全面下线
  - dao/agent.go:saveChatHistoryCore / SaveChatHistory / SaveChatHistoryInTx 移除 retry_group_id / retry_index /
  retry_from_user_message_id / retry_from_assistant_message_id 四个参数,修复乱码注释
  - dao/agent-cache.go:移除 ApplyRetrySeed 和 extractMessageHistoryID 两个方法
  - conv/agent.go:ToEinoMessages 不再回灌 retry_* 字段到运行期上下文
  - service/agentsvc/agent.go:移除 chatRetryMeta 及 resolveRetryGroupID / buildRetrySeed 等全部重试逻辑
  - service/agentsvc/agent_quick_note.go:整个文件删除(retry 快速补写路径已无用)
  - service/events/chat_history_persist.go:移除 retry 参数传递
5. 节点层瘦身 + 可见消息逐条持久化
  - agent_nodes.go 大幅简化:Chat/Plan/Execute/Deliver 节点方法移除 ToolSchema 注入、状态摘要渲染等逻辑,只做参数转发和状态落盘
  - 新增 visible_message.go:persistVisibleAssistantMessage 统一处理可见 assistant speak 的实时持久化,失败仅记日志不中断主流程
  - 新增 llm_debug.go:logNodeLLMContext 统一打印 LLM 上下文调试日志
  - graph_run_state.go 新增 PersistVisibleMessageFunc 类型 + AgentGraphDeps.PersistVisibleMessage 字段
  - service/agentsvc/agent_newagent.go 精简主循环,注入 PersistVisibleMessage 回调;agent_history.go 精简历史构建
  - token_budget.go 移除 Execute 专属预算检查,统一到通用预算

前端:
1. 移除 retry 相关 UI 和类型
  - agent.ts 移除 retry_group_id / retry_index / retry_total 字段及 normalize 逻辑
  - AssistantPanel.vue 移除 retry 相关 UI 和交互代码(约 700 行精简)
  - dashboard.ts 移除 retry 相关类型定义
  - AssistantView.vue 微调
2. ContextWindowMeter 压缩次数展示和数值格式优化
  - 新增 formatCompactCount 工具函数,千位以上用 k 单位压缩(如 80k)
  - 新增压缩次数显示
3.修复了新对话发消息时,user和assistant消息被自动调换的bug

仓库:无
This commit is contained in:
Losita
2026-04-17 22:19:38 +08:00
parent d47a8bcabd
commit d8280cc647
39 changed files with 2095 additions and 2386 deletions

View File

@@ -3,14 +3,12 @@ package agentsvc
import (
"context"
"encoding/json"
"errors"
"log"
"strconv"
"strings"
"time"
agentchat "github.com/LoveLosita/smartflow/backend/agent/chat"
agentrouter "github.com/LoveLosita/smartflow/backend/agent/router"
"github.com/LoveLosita/smartflow/backend/conv"
"github.com/LoveLosita/smartflow/backend/dao"
outboxinfra "github.com/LoveLosita/smartflow/backend/infra/outbox"
@@ -21,7 +19,6 @@ import (
newagentmodel "github.com/LoveLosita/smartflow/backend/newAgent/model"
newagenttools "github.com/LoveLosita/smartflow/backend/newAgent/tools"
"github.com/LoveLosita/smartflow/backend/pkg"
"github.com/LoveLosita/smartflow/backend/respond"
eventsvc "github.com/LoveLosita/smartflow/backend/service/events"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
@@ -129,10 +126,6 @@ func (s *AgentService) PersistChatHistory(ctx context.Context, payload model.Cha
payload.Message,
payload.ReasoningContent,
payload.ReasoningDurationSeconds,
payload.RetryGroupID,
payload.RetryIndex,
payload.RetryFromUserMessageID,
payload.RetryFromAssistantMessageID,
payload.TokensConsumed,
)
}
@@ -159,112 +152,6 @@ func mergeAgentReasoningText(parts ...string) string {
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")
// 1. retry 请求必须明确指向“被重试的那一轮 user + assistant”。
// 2. 若这里拿不到有效父消息 id继续写库只会生成一组孤立的 index=1 重试消息。
// 3. 因此直接拒绝本次请求,让前端刷新历史后重试,比静默写脏数据更安全。
if sourceUserMessageID <= 0 || sourceAssistantMessageID <= 0 {
return nil, errors.New("重试请求缺少有效的父消息ID请刷新会话后重试")
}
// 4. 再进一步校验父消息确实属于当前用户与当前会话,且角色语义正确。
// 5. 这样即便前端误把占位 id 或串号 id 发过来,后端也不会继续落错库。
if err := s.repo.ValidateRetrySourceMessages(ctx, userID, chatID, sourceUserMessageID, sourceAssistantMessageID); err != nil {
return nil, errors.New("重试引用的父消息无效,请刷新会话后重试")
}
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 ""
@@ -400,9 +287,9 @@ func (s *AgentService) runNormalChatFlow(
selectedModel *ark.ChatModel,
resolvedModelName string,
userMessage string,
userPersisted bool,
assistantReasoningPrefix string,
assistantReasoningStartedAt *time.Time,
retryMeta *chatRetryMeta,
ifThinking bool,
userID int,
chatID string,
@@ -481,44 +368,38 @@ func (s *AgentService) runNormalChatFlow(
// 7. 后置持久化(用户消息):
// 7.1 先写 Redis保证“最新会话上下文”可立即用于下一轮推理
// 7.2 再走可靠持久化入口outbox 或同步 DB
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 !userPersisted {
userMsg := &schema.Message{Role: schema.User, Content: userMessage}
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,
ReasoningContent: "",
ReasoningDurationSeconds: 0,
RetryGroupID: retryMeta.GroupIDPtr(),
RetryIndex: retryMeta.IndexPtr(),
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
// 口径B用户消息固定记 0本轮总 token 统一记在助手消息。
TokensConsumed: 0,
}); err != nil {
pushErrNonBlocking(errChan, err)
return
if err = s.PersistChatHistory(ctx, model.ChatHistoryPersistPayload{
UserID: userID,
ConversationID: chatID,
Role: "user",
Message: userMessage,
ReasoningContent: "",
ReasoningDurationSeconds: 0,
// 口径 B用户消息固定记 0本轮总 token 统一记在助手消息。
TokensConsumed: 0,
}); err != nil {
pushErrNonBlocking(errChan, err)
return
}
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"user",
userMessage,
"",
0,
requestStart,
),
)
}
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"user",
userMessage,
"",
0,
retryMeta,
requestStart,
),
)
// 普通聊天链路也需要把助手回复写入 Redis
// 否则会出现“数据库有助手消息,但 Redis 最新会话只有用户消息”的口径不一致。
@@ -529,29 +410,17 @@ func (s *AgentService) runNormalChatFlow(
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,
ReasoningContent: assistantReasoning,
ReasoningDurationSeconds: reasoningDurationSeconds,
RetryGroupID: retryMeta.GroupIDPtr(),
RetryIndex: retryMeta.IndexPtr(),
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
UserID: userID,
ConversationID: chatID,
Role: "assistant",
Message: fullText,
ReasoningContent: assistantReasoning,
ReasoningDurationSeconds: reasoningDurationSeconds,
// 口径B助手消息记录“本轮请求总 token”。
TokensConsumed: requestTotalTokens,
}); saveErr != nil {
@@ -566,7 +435,6 @@ func (s *AgentService) runNormalChatFlow(
fullText,
assistantReasoning,
reasoningDurationSeconds,
retryMeta,
time.Now(),
),
)
@@ -591,196 +459,3 @@ func (s *AgentService) AgentChat(ctx context.Context, userMessage string, thinki
return outChan, errChan
}
// agentChatOld 是旧路由逻辑的备份,暂时保留供回滚使用。
// TODO: 新 graph 稳定后删除。
func (s *AgentService) agentChatOld(ctx context.Context, userMessage string, thinkingMode string, modelName string, userID int, chatID string, extra map[string]any) (<-chan string, <-chan error) {
ifThinking := thinkingModeToBool(thinkingMode)
requestStart := time.Now()
traceID := uuid.NewString()
outChan := make(chan string, 256)
errChan := make(chan error, 1)
// 0. 初始化”请求级 token 统计器”,用于聚合本次请求所有模型开销。
requestCtx, _ := withRequestTokenMeter(ctx)
// 1) 规范会话 ID选择模型。
chatID = normalizeConversationID(chatID)
selectedModel, resolvedModelName := s.pickChatModel(modelName)
// 2) 确保会话存在(优先缓存,必要时回源 DB 并创建)。
// 2.1 先查 Redis 会话标记,命中则可跳过 DB 存在性校验。
result, err := s.agentCache.GetConversationStatus(requestCtx, chatID)
if err != nil {
errChan <- err
close(outChan)
close(errChan)
return outChan, errChan
}
if !result {
// 2.2 缓存未命中时回源 DB确认会话是否存在。
innerResult, ifErr := s.repo.IfChatExists(requestCtx, userID, chatID)
if ifErr != nil {
errChan <- ifErr
close(outChan)
close(errChan)
return outChan, errChan
}
if !innerResult {
// 2.3 DB 里也不存在则创建新会话。
if _, err = s.repo.CreateNewChat(userID, chatID); err != nil {
errChan <- err
close(outChan)
close(errChan)
return outChan, errChan
}
}
// 2.4 补写 Redis 会话标记,优化下次访问。
if err = s.agentCache.SetConversationStatus(requestCtx, chatID); err != nil {
log.Printf("设置会话状态缓存失败 chat=%s: %v", chatID, err)
}
}
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
// 3.3 task_query 进入任务查询 tool-calling
// 3.4 chat 直接普通流式聊天。
go func() {
defer close(outChan)
// 3.1 先走轻量路由,拿到统一 action。
routing := s.decideActionRouting(requestCtx, selectedModel, userMessage)
if routing.RouteFailed {
// 3.1.1 路由码失败不再回落聊天。
// 3.1.2 直接返回内部错误,避免误进入业务分支导致“吐错内容”(例如吐排程 JSON
pushErrNonBlocking(errChan, respond.RouteControlInternalError)
return
}
// 3.2 chat直接走普通聊天主链路。
if routing.Action == agentrouter.ActionChat {
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, "", nil, retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 3.3 非 chat 分支统一先发“接收成功”阶段,减少用户等待时的“无反馈感”。
progress := newQuickNoteProgressEmitter(outChan, resolvedModelName, true)
progress.Emit("request.accepted", routing.Detail)
// 3.4 quick_note_create执行随口记 graph。
if routing.Action == agentrouter.ActionQuickNoteCreate {
quickHandled, quickState, quickErr := s.tryHandleQuickNoteWithGraph(
requestCtx,
selectedModel,
userMessage,
userID,
chatID,
traceID,
routing.TrustRoute,
progress.Emit,
)
if quickErr != nil {
// graph 出错不直接中断用户请求,而是回退普通聊天,保证可用性优先。
log.Printf("随口记 graph 执行失败,回退普通聊天 trace_id=%s chat_id=%s err=%v", traceID, chatID, quickErr)
}
if quickHandled {
// 3.4.1 随口记处理成功:组织最终回复并按 OpenAI 兼容格式输出。
progress.Emit("quick_note.reply.polishing", "正在结合你的话题润色回复。")
quickReply := buildQuickNoteFinalReply(requestCtx, selectedModel, userMessage, quickState)
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, quickReply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
// 3.4.2 对随口记回复执行统一后置持久化Redis + outbox/DB
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
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
}
// 3.4.4 路由误判或 graph 判定非随口记时,回落普通聊天,保证“能聊”。
progress.Emit("quick_note.fallback", "当前输入不是随口记请求,切换到普通对话。")
s.runNormalChatFlow(requestCtx, selectedModel, resolvedModelName, userMessage, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 3.5 task_query执行任务查询 tool-calling。
if routing.Action == agentrouter.ActionTaskQuery {
reply, queryErr := s.runTaskQueryFlow(requestCtx, selectedModel, userMessage, userID, progress.Emit)
if queryErr != nil {
// 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, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 3.5.2 查询成功后按 OpenAI 兼容格式输出,并执行统一后置持久化。
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, progress.HistoryText(), progress.DurationSeconds(time.Now()), retryMeta, 0, requestTotalTokens, errChan)
s.ensureConversationTitleAsync(userID, chatID)
return
}
// 3.6 schedule_plan执行智能排程 graph。
if routing.Action == agentrouter.ActionSchedulePlanCreate {
reply, planErr := s.runSchedulePlanFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, extra, progress.Emit, outChan, resolvedModelName)
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, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
return
}
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
s.persistChatAfterReply(requestCtx, userID, chatID, userMessage, reply, progress.HistoryText(), progress.DurationSeconds(time.Now()), retryMeta, 0, requestTotalTokens, errChan)
s.ensureConversationTitleAsync(userID, chatID)
return
}
// 3.7 schedule_plan_refine执行“连续微调排程”graph。
if routing.Action == agentrouter.ActionSchedulePlanRefine {
reply, refineErr := s.runScheduleRefineFlow(requestCtx, selectedModel, userMessage, userID, chatID, traceID, progress.Emit, outChan, resolvedModelName)
if refineErr != nil {
// 连续微调失败不再回落普通聊天,直接上报错误。
pushErrNonBlocking(errChan, refineErr)
return
}
if emitErr := emitSingleAssistantCompletion(outChan, resolvedModelName, reply); emitErr != nil {
pushErrNonBlocking(errChan, emitErr)
return
}
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
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, progress.HistoryText(), progress.StartedAt(), retryMeta, ifThinking, userID, chatID, traceID, requestStart, outChan, errChan)
}()
return outChan, errChan
}

View File

@@ -118,7 +118,6 @@ func (s *AgentService) appendConversationHistoryCacheOptimistically(
merged = appendConversationHistoryItemIfMissing(merged, item)
}
sortConversationHistoryItems(merged)
merged = attachConversationRetryTotals(merged)
if err = s.cacheDAO.SetConversationHistoryToCache(ctx, userID, normalizedChatID, merged); err != nil {
log.Printf("乐观追加会话历史视图缓存失败 chat_id=%s: %v", normalizedChatID, err)
@@ -151,11 +150,9 @@ func buildConversationHistoryItemsFromDB(histories []model.ChatHistory) []model.
CreatedAt: history.CreatedAt,
ReasoningContent: strings.TrimSpace(derefConversationHistoryText(history.ReasoningContent)),
ReasoningDurationSeconds: history.ReasoningDurationSeconds,
RetryGroupID: cloneConversationStringPointer(history.RetryGroupID),
RetryIndex: cloneConversationIntPointer(history.RetryIndex),
})
}
return attachConversationRetryTotals(items)
return items
}
func derefConversationHistoryText(text *string) string {
@@ -165,58 +162,6 @@ func derefConversationHistoryText(text *string) string {
return *text
}
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":
@@ -245,7 +190,6 @@ func buildOptimisticConversationHistoryItem(
content string,
reasoningContent string,
reasoningDurationSeconds int,
retryMeta *chatRetryMeta,
createdAt time.Time,
) model.GetConversationHistoryItem {
item := model.GetConversationHistoryItem{
@@ -258,11 +202,6 @@ func buildOptimisticConversationHistoryItem(
t := createdAt
item.CreatedAt = &t
}
if retryMeta != nil {
item.RetryGroupID = retryMeta.GroupIDPtr()
item.RetryIndex = retryMeta.IndexPtr()
item.RetryTotal = retryMeta.IndexPtr()
}
return item
}
@@ -284,26 +223,16 @@ func conversationHistoryItemSignature(item model.GetConversationHistoryItem) str
return fmt.Sprintf("id:%d", item.ID)
}
groupID := ""
if item.RetryGroupID != nil {
groupID = strings.TrimSpace(*item.RetryGroupID)
}
retryIndex := 0
if item.RetryIndex != nil {
retryIndex = *item.RetryIndex
}
createdAt := ""
if item.CreatedAt != nil {
createdAt = item.CreatedAt.UTC().Format(time.RFC3339Nano)
}
return fmt.Sprintf(
"%s|%s|%s|%s|%d|%d|%s",
"%s|%s|%s|%d|%s",
strings.TrimSpace(item.Role),
strings.TrimSpace(item.Content),
strings.TrimSpace(item.ReasoningContent),
groupID,
retryIndex,
item.ReasoningDurationSeconds,
createdAt,
)

View File

@@ -84,12 +84,7 @@ func (s *AgentService) runNewAgentGraph(
}
}
// 3. 构建重试元数据。
retryMeta, err := s.buildChatRetryMeta(requestCtx, userID, chatID, extra)
if err != nil {
pushErrNonBlocking(errChan, err)
return
}
// 3. retry 机制已下线,不再构建重试元数据。
// 4. 从 StateStore 加载或创建 RuntimeState。
// 恢复场景confirm/ask_user同时拿到快照中保存的 ConversationContext
@@ -137,6 +132,31 @@ func (s *AgentService) runNewAgentGraph(
}
}
cs = runtimeState.EnsureCommonState()
// 5.7 先把本轮用户输入落库,确保后续可见 assistant 消息按真实时间线追加。
userMsg := schema.UserMessage(userMessage)
if err := s.persistNewAgentConversationMessage(requestCtx, userID, chatID, userMsg, 0); err != nil {
pushErrNonBlocking(errChan, err)
return
}
persistVisibleMessage := func(persistCtx context.Context, state *newagentmodel.CommonState, msg *schema.Message) error {
targetState := state
if targetState == nil {
targetState = runtimeState.EnsureCommonState()
}
if targetState != nil {
if targetState.UserID <= 0 {
targetState.UserID = userID
}
if strings.TrimSpace(targetState.ConversationID) == "" {
targetState.ConversationID = chatID
}
}
return s.persistNewAgentConversationMessage(persistCtx, userID, chatID, msg, 0)
}
// 6. 构造 AgentGraphRequest。
var confirmAction string
if len(extra) > 0 {
@@ -163,22 +183,23 @@ func (s *AgentService) runNewAgentGraph(
// 9. 构造 AgentGraphDeps由 cmd/start.go 注入的依赖)。
deps := newagentmodel.AgentGraphDeps{
ChatClient: chatClient,
PlanClient: planClient,
ExecuteClient: executeClient,
DeliverClient: deliverClient,
ChunkEmitter: chunkEmitter,
StateStore: s.agentStateStore,
ToolRegistry: s.toolRegistry,
ScheduleProvider: s.scheduleProvider,
SchedulePersistor: s.schedulePersistor,
CompactionStore: s.compactionStore,
RoughBuildFunc: s.makeRoughBuildFunc(),
WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(),
MemoryFuture: memoryFuture,
ThinkingPlan: viper.GetBool("agent.thinking.plan"),
ThinkingExecute: viper.GetBool("agent.thinking.execute"),
ThinkingDeliver: viper.GetBool("agent.thinking.deliver"),
ChatClient: chatClient,
PlanClient: planClient,
ExecuteClient: executeClient,
DeliverClient: deliverClient,
ChunkEmitter: chunkEmitter,
StateStore: s.agentStateStore,
ToolRegistry: s.toolRegistry,
ScheduleProvider: s.scheduleProvider,
SchedulePersistor: s.schedulePersistor,
CompactionStore: s.compactionStore,
RoughBuildFunc: s.makeRoughBuildFunc(),
WriteSchedulePreview: s.makeWriteSchedulePreviewFunc(),
MemoryFuture: memoryFuture,
ThinkingPlan: viper.GetBool("agent.thinking.plan"),
ThinkingExecute: viper.GetBool("agent.thinking.execute"),
ThinkingDeliver: viper.GetBool("agent.thinking.deliver"),
PersistVisibleMessage: persistVisibleMessage,
}
// 10. 构造 AgentGraphRunInput 并运行 graph。
@@ -197,12 +218,13 @@ func (s *AgentService) runNewAgentGraph(
pushErrNonBlocking(errChan, fmt.Errorf("graph 执行失败: %w", graphErr))
// Graph 出错时回退普通聊天,保证可用性。回退使用 Pro 模型。
s.runNormalChatFlow(requestCtx, s.AIHub.Pro, resolvedModelName, userMessage, "", nil, retryMeta, thinkingModeToBool(thinkingMode), userID, chatID, traceID, requestStart, outChan, errChan)
s.runNormalChatFlow(requestCtx, s.AIHub.Pro, resolvedModelName, userMessage, true, "", nil, thinkingModeToBool(thinkingMode), userID, chatID, traceID, requestStart, outChan, errChan)
return
}
// 11. 持久化聊天历史(用户消息 + 助手回复)。
s.persistChatAfterGraph(requestCtx, userID, chatID, userMessage, finalState, retryMeta, requestStart, outChan, errChan)
requestTotalTokens := snapshotRequestTokenMeter(requestCtx).TotalTokens
s.adjustNewAgentRequestTokenUsage(requestCtx, userID, chatID, requestTotalTokens)
// 11.5. 将最终状态快照异步写入 MySQL通过 outbox
// Deliver 节点已将快照保存到 Redis2h TTL此处通过 outbox 异步写入 MySQL 做永久存储。
if finalState != nil {
@@ -369,135 +391,89 @@ func (s *AgentService) loadConversationContext(ctx context.Context, chatID, user
return conversationContext
}
// persistChatAfterGraph graph 执行完成后持久化聊天历史
func (s *AgentService) persistChatAfterGraph(
// persistNewAgentConversationMessage 负责把 newAgent 链路里"真正对用户可见"的消息统一落到 Redis + MySQL
//
// 职责边界:
// 1. 只做单条消息的持久化,不做 graph 流程控制;
// 2. TokensConsumed 由调用方显式传入newAgent 逐条可见消息默认写 0
// 3. Redis 失败只记日志DB 失败返回错误,便于调用方决定是否中止当前链路。
func (s *AgentService) persistNewAgentConversationMessage(
ctx context.Context,
userID int,
chatID string,
userMessage string,
finalState *newagentmodel.AgentGraphState,
retryMeta *chatRetryMeta,
requestStart time.Time,
outChan chan<- string,
errChan chan error,
) {
if finalState == nil {
return
msg *schema.Message,
tokensConsumed int,
) error {
if s == nil || msg == nil {
return nil
}
role := strings.TrimSpace(string(msg.Role))
content := strings.TrimSpace(msg.Content)
if role == "" || content == "" {
return nil
}
if userID <= 0 || strings.TrimSpace(chatID) == "" {
return fmt.Errorf("newAgent visible message persist: invalid conversation identity")
}
if ctx == nil {
ctx = context.Background()
}
// 1. 持久化用户消息:先写 LLM 上下文 Redis再落 DB最后更新 UI 历史缓存。
userMsg := &schema.Message{Role: schema.User, Content: userMessage}
if retryExtra := retryMeta.CacheExtra(); len(retryExtra) > 0 {
userMsg.Extra = retryExtra
persistMsg := &schema.Message{
Role: msg.Role,
Content: content,
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
}
if err := s.agentCache.PushMessage(ctx, chatID, userMsg); err != nil {
log.Printf("写入用户消息到 LLM 上下文 Redis 失败 chat=%s: %v", chatID, err)
if len(msg.Extra) > 0 {
persistMsg.Extra = make(map[string]any, len(msg.Extra))
for key, value := range msg.Extra {
persistMsg.Extra[key] = value
}
}
userPayload := model.ChatHistoryPersistPayload{
UserID: userID,
ConversationID: chatID,
Role: "user",
Message: userMessage,
ReasoningContent: "",
ReasoningDurationSeconds: 0,
RetryGroupID: retryMeta.GroupIDPtr(),
RetryIndex: retryMeta.IndexPtr(),
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
TokensConsumed: 0,
if err := s.agentCache.PushMessage(ctx, chatID, persistMsg); err != nil {
log.Printf("写入 newAgent 可见消息到 Redis 失败 chat=%s role=%s: %v", chatID, role, err)
}
if err := s.PersistChatHistory(ctx, userPayload); err != nil {
pushErrNonBlocking(errChan, err)
reasoningDurationSeconds := 0
if persistMsg.Extra != nil {
switch v := persistMsg.Extra["reasoning_duration_seconds"].(type) {
case int:
reasoningDurationSeconds = v
case int64:
reasoningDurationSeconds = int(v)
case float64:
reasoningDurationSeconds = int(v)
}
}
userCreatedAt := time.Now()
persistPayload := model.ChatHistoryPersistPayload{
UserID: userID,
ConversationID: chatID,
Role: role,
Message: content,
ReasoningContent: strings.TrimSpace(persistMsg.ReasoningContent),
ReasoningDurationSeconds: reasoningDurationSeconds,
TokensConsumed: tokensConsumed,
}
if err := s.PersistChatHistory(ctx, persistPayload); err != nil {
return err
}
now := time.Now()
s.appendConversationHistoryCacheOptimistically(
context.Background(),
ctx,
userID,
chatID,
buildOptimisticConversationHistoryItem("user", userMessage, "", 0, retryMeta, userCreatedAt),
buildOptimisticConversationHistoryItem(
role,
content,
persistPayload.ReasoningContent,
reasoningDurationSeconds,
now,
),
)
// 2. 从 ConversationContext 提取助手回复(最后一条 assistant 消息)。
conversationContext := finalState.ConversationContext
if conversationContext == nil || len(conversationContext.History) == 0 {
return
}
var lastAssistantMsg *schema.Message
for i := len(conversationContext.History) - 1; i >= 0; i-- {
msg := conversationContext.History[i]
if msg.Role == schema.Assistant {
lastAssistantMsg = msg
break
}
}
if lastAssistantMsg == nil {
return
}
assistantReply := lastAssistantMsg.Content
reasoningContent := lastAssistantMsg.ReasoningContent
var reasoningDurationSeconds int
if lastAssistantMsg.Extra != nil {
if dur, ok := lastAssistantMsg.Extra["reasoning_duration_seconds"].(float64); ok {
reasoningDurationSeconds = int(dur)
}
}
// 3. 持久化助手消息:先写 LLM 上下文 Redis再落 DB最后更新 UI 历史缓存。
assistantMsg := &schema.Message{
Role: schema.Assistant,
Content: assistantReply,
ReasoningContent: reasoningContent,
}
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)
}
for k, v := range retryExtra {
assistantMsg.Extra[k] = v
}
}
if err := s.agentCache.PushMessage(context.Background(), chatID, assistantMsg); err != nil {
log.Printf("写入助手消息到 LLM 上下文 Redis 失败 chat=%s: %v", chatID, err)
}
requestTotalTokens := snapshotRequestTokenMeter(ctx).TotalTokens
assistantPayload := model.ChatHistoryPersistPayload{
UserID: userID,
ConversationID: chatID,
Role: "assistant",
Message: assistantReply,
ReasoningContent: reasoningContent,
ReasoningDurationSeconds: reasoningDurationSeconds,
RetryGroupID: retryMeta.GroupIDPtr(),
RetryIndex: retryMeta.IndexPtr(),
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
TokensConsumed: requestTotalTokens,
}
if err := s.PersistChatHistory(ctx, assistantPayload); err != nil {
pushErrNonBlocking(errChan, err)
} else {
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"assistant",
assistantReply,
reasoningContent,
reasoningDurationSeconds,
retryMeta,
time.Now(),
),
)
}
return nil
}
// makeRoughBuildFunc 把 AgentService 上的 HybridScheduleWithPlanMultiFunc 封装成
@@ -509,6 +485,38 @@ func (s *AgentService) persistChatAfterGraph(
// placement普通时段放置的任务全部被丢弃。
// 正确做法:使用第一个返回值 []HybridScheduleEntry过滤 Status="suggested" 且 TaskItemID>0 的条目,
// 这样嵌入和非嵌入的粗排结果都能正确写入 ScheduleState。
// adjustNewAgentRequestTokenUsage 负责把本轮 graph 的请求级 token 一次性回写到账本。
//
// 说明:
// 1. newAgent 逐条可见消息都按 0 token 落库,最终统一在这里补记整轮消耗;
// 2. 如果启用了 outbox就沿用异步 token 调整事件,保持写账口径一致;
// 3. 该步骤属于请求收尾,不应反过来打断用户已看到的回复。
func (s *AgentService) adjustNewAgentRequestTokenUsage(ctx context.Context, userID int, chatID string, deltaTokens int) {
if s == nil || userID <= 0 || strings.TrimSpace(chatID) == "" || deltaTokens <= 0 {
return
}
if ctx == nil {
ctx = context.Background()
}
if s.eventPublisher != nil {
if err := eventsvc.PublishChatTokenUsageAdjustRequested(ctx, s.eventPublisher, model.ChatTokenUsageAdjustPayload{
UserID: userID,
ConversationID: chatID,
TokensDelta: deltaTokens,
Reason: "new_agent_request",
TriggeredAt: time.Now(),
}); err != nil {
log.Printf("写入 newAgent 请求级 token 调整事件失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err)
}
return
}
if err := s.repo.AdjustTokenUsage(ctx, userID, chatID, deltaTokens); err != nil {
log.Printf("同步写入 newAgent 请求级 token 调整失败 chat=%s tokens=%d err=%v", chatID, deltaTokens, err)
}
}
func (s *AgentService) makeRoughBuildFunc() newagentmodel.RoughBuildFunc {
if s.HybridScheduleWithPlanMultiFunc == nil {
return nil

View File

@@ -15,7 +15,6 @@ import (
agentstream "github.com/LoveLosita/smartflow/backend/agent/stream"
"github.com/LoveLosita/smartflow/backend/model"
"github.com/cloudwego/eino-ext/components/model/ark"
"github.com/cloudwego/eino/schema"
"github.com/google/uuid"
)
@@ -302,109 +301,3 @@ func (s *AgentService) decideQuickNoteRouting(ctx context.Context, selectedModel
_ = s
return agentrouter.DecideQuickNoteRouting(ctx, selectedModel, userMessage)
}
// persistChatAfterReply 在“随口记 graph”返回后复用当前项目的后置持久化策略
// 1) 用户消息写 Redis + outbox/DB
// 2) 助手消息写 Redis + outbox/DB。
func (s *AgentService) persistChatAfterReply(
ctx context.Context,
userID int,
chatID string,
userMessage string,
assistantReply string,
assistantReasoning string,
assistantReasoningDurationSeconds int,
retryMeta *chatRetryMeta,
userTokens int,
assistantTokens int,
errChan chan error,
) {
// 1. 先把用户消息写入 Redis保证会话上下文“马上可见”。
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,
ReasoningContent: "",
ReasoningDurationSeconds: 0,
RetryGroupID: retryMeta.GroupIDPtr(),
RetryIndex: retryMeta.IndexPtr(),
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
TokensConsumed: userTokens,
}); err != nil {
pushErrNonBlocking(errChan, err)
return
}
userCreatedAt := time.Now()
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"user",
userMessage,
"",
0,
retryMeta,
userCreatedAt,
),
)
// 3. 助手消息同样遵循“Redis 先行 + 可靠持久化补齐”策略。
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,
ReasoningContent: assistantReasoning,
ReasoningDurationSeconds: assistantReasoningDurationSeconds,
RetryGroupID: retryMeta.GroupIDPtr(),
RetryIndex: retryMeta.IndexPtr(),
RetryFromUserMessageID: retryMeta.FromUserMessageIDPtr(),
RetryFromAssistantMessageID: retryMeta.FromAssistantMessageIDPtr(),
TokensConsumed: assistantTokens,
}); err != nil {
pushErrNonBlocking(errChan, err)
return
}
s.appendConversationHistoryCacheOptimistically(
context.Background(),
userID,
chatID,
buildOptimisticConversationHistoryItem(
"assistant",
assistantReply,
assistantReasoning,
assistantReasoningDurationSeconds,
retryMeta,
userCreatedAt.Add(time.Millisecond),
),
)
}

View File

@@ -71,10 +71,6 @@ func RegisterChatHistoryPersistHandler(
payload.Message,
payload.ReasoningContent,
payload.ReasoningDurationSeconds,
payload.RetryGroupID,
payload.RetryIndex,
payload.RetryFromUserMessageID,
payload.RetryFromAssistantMessageID,
payload.TokensConsumed,
); err != nil {
return err