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:
@@ -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 先走“通用控制码路由”决定 action(chat / 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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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 节点已将快照保存到 Redis(2h 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
|
||||
|
||||
@@ -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),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user