后端: 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 仓库:无
305 lines
12 KiB
Go
305 lines
12 KiB
Go
package model
|
||
|
||
import (
|
||
"encoding/json"
|
||
"fmt"
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// AgentResumeType 表示本轮请求想恢复哪一类挂起交互。
|
||
type AgentResumeType string
|
||
|
||
const (
|
||
AgentResumeTypeAskUser AgentResumeType = "ask_user"
|
||
AgentResumeTypeConfirm AgentResumeType = "confirm"
|
||
AgentResumeTypeConnectionRecover AgentResumeType = "connection_recover"
|
||
)
|
||
|
||
// AgentResumeAction 表示用户这次恢复请求携带的动作类型。
|
||
type AgentResumeAction string
|
||
|
||
const (
|
||
AgentResumeActionReply AgentResumeAction = "reply"
|
||
AgentResumeActionApprove AgentResumeAction = "approve"
|
||
AgentResumeActionReject AgentResumeAction = "reject"
|
||
AgentResumeActionCancel AgentResumeAction = "cancel"
|
||
AgentResumeActionResume AgentResumeAction = "resume"
|
||
)
|
||
|
||
// AgentResumeRequest 是 extra.resume 的统一结构。
|
||
//
|
||
// 设计目的:
|
||
// 1. 继续复用现有聊天入口,不再额外新增一条“确认专用接口”;
|
||
// 2. 前端只提交“我要恢复哪次交互、这次动作是什么”,不直接改后端 state;
|
||
// 3. 后端进入聊天主链路前,先读取这份结构,再决定走 confirm / ask_user / connection_recover 哪条恢复路径。
|
||
//
|
||
// 推荐前端请求形态:
|
||
//
|
||
// {
|
||
// "message": "",
|
||
// "extra": {
|
||
// "resume": {
|
||
// "interaction_id": "xxx",
|
||
// "type": "confirm",
|
||
// "action": "approve"
|
||
// }
|
||
// }
|
||
// }
|
||
//
|
||
// TODO(newagent/api): 进入聊天主流程前,优先调用 req.ResumeRequest();若命中恢复协议,则不要把本轮请求按普通聊天处理。
|
||
type AgentResumeRequest struct {
|
||
InteractionID string `json:"interaction_id"`
|
||
Type AgentResumeType `json:"type,omitempty"`
|
||
Action AgentResumeAction `json:"action"`
|
||
}
|
||
|
||
type UserSendMessageRequest struct {
|
||
ConversationID string `json:"conversation_id,omitempty"`
|
||
Message string `json:"message" binding:"required"`
|
||
Model string `json:"model,omitempty"`
|
||
Thinking string `json:"thinking,omitempty"`
|
||
Extra map[string]any `json:"extra,omitempty"`
|
||
}
|
||
|
||
// ResumeRequest 从 extra.resume 中解析结构化恢复请求。
|
||
//
|
||
// 步骤说明:
|
||
// 1. 若 extra 或 extra.resume 不存在,则直接返回 nil,表示本轮是普通聊天请求;
|
||
// 2. 先把任意 map/struct 形态统一转成 JSON,再反序列化到强类型结构,避免入口层到处手写断言;
|
||
// 3. 解析成功后先做 Normalize,再做最小必要校验,防止后续业务层拿到脏协议继续流转;
|
||
// 4. 这里只负责协议解析与基本校验,不负责真正恢复状态,也不负责改 Redis/MySQL。
|
||
func (r *UserSendMessageRequest) ResumeRequest() (*AgentResumeRequest, error) {
|
||
if r == nil || len(r.Extra) == 0 {
|
||
return nil, nil
|
||
}
|
||
|
||
rawResume, ok := r.Extra["resume"]
|
||
if !ok || rawResume == nil {
|
||
return nil, nil
|
||
}
|
||
|
||
data, err := json.Marshal(rawResume)
|
||
if err != nil {
|
||
return nil, fmt.Errorf("序列化 extra.resume 失败: %w", err)
|
||
}
|
||
|
||
var resume AgentResumeRequest
|
||
if err := json.Unmarshal(data, &resume); err != nil {
|
||
return nil, fmt.Errorf("解析 extra.resume 失败: %w", err)
|
||
}
|
||
|
||
resume.Normalize()
|
||
if err := resume.Validate(); err != nil {
|
||
return nil, err
|
||
}
|
||
return &resume, nil
|
||
}
|
||
|
||
// Normalize 统一清洗恢复协议中的字符串字段。
|
||
func (r *AgentResumeRequest) Normalize() {
|
||
if r == nil {
|
||
return
|
||
}
|
||
r.InteractionID = strings.TrimSpace(r.InteractionID)
|
||
r.Type = AgentResumeType(strings.TrimSpace(string(r.Type)))
|
||
r.Action = AgentResumeAction(strings.TrimSpace(string(r.Action)))
|
||
}
|
||
|
||
// Validate 校验恢复协议的最小合法性。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只校验“是否像一份合法的恢复协议”,不校验 interaction_id 是否真实存在;
|
||
// 2. confirm / ask_user / connection_recover 共用一条入口,但动作集合不同,所以这里做显式分流校验;
|
||
// 3. 对于 ask_user 回复,真正的回答正文仍建议优先放在顶层 message,这里不强制要求额外 answer 字段。
|
||
func (r *AgentResumeRequest) Validate() error {
|
||
if r == nil {
|
||
return nil
|
||
}
|
||
if r.InteractionID == "" {
|
||
return fmt.Errorf("extra.resume.interaction_id 不能为空")
|
||
}
|
||
if r.Action == "" {
|
||
return fmt.Errorf("extra.resume.action 不能为空")
|
||
}
|
||
|
||
switch r.Type {
|
||
case "", AgentResumeTypeConfirm:
|
||
switch r.Action {
|
||
case AgentResumeActionApprove, AgentResumeActionReject, AgentResumeActionCancel:
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("confirm 恢复动作非法: %s", r.Action)
|
||
}
|
||
case AgentResumeTypeAskUser:
|
||
switch r.Action {
|
||
case AgentResumeActionReply, AgentResumeActionCancel:
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("ask_user 恢复动作非法: %s", r.Action)
|
||
}
|
||
case AgentResumeTypeConnectionRecover:
|
||
switch r.Action {
|
||
case AgentResumeActionResume, AgentResumeActionCancel:
|
||
return nil
|
||
default:
|
||
return fmt.Errorf("connection_recover 恢复动作非法: %s", r.Action)
|
||
}
|
||
default:
|
||
return fmt.Errorf("extra.resume.type 非法: %s", r.Type)
|
||
}
|
||
}
|
||
|
||
// IsConfirmResume 判断当前恢复请求是否属于 confirm 分支。
|
||
func (r *AgentResumeRequest) IsConfirmResume() bool {
|
||
if r == nil {
|
||
return false
|
||
}
|
||
return r.Type == "" || r.Type == AgentResumeTypeConfirm
|
||
}
|
||
|
||
// IsAskUserResume 判断当前恢复请求是否属于 ask_user 分支。
|
||
func (r *AgentResumeRequest) IsAskUserResume() bool {
|
||
if r == nil {
|
||
return false
|
||
}
|
||
return r.Type == AgentResumeTypeAskUser
|
||
}
|
||
|
||
// IsConnectionRecoverResume 判断当前恢复请求是否属于 connection_recover 分支。
|
||
func (r *AgentResumeRequest) IsConnectionRecoverResume() bool {
|
||
if r == nil {
|
||
return false
|
||
}
|
||
return r.Type == AgentResumeTypeConnectionRecover
|
||
}
|
||
|
||
type ChatHistoryPersistPayload struct {
|
||
UserID int `json:"user_id"`
|
||
ConversationID string `json:"conversation_id"`
|
||
Role string `json:"role"`
|
||
Message string `json:"message"`
|
||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||
ReasoningDurationSeconds int `json:"reasoning_duration_seconds,omitempty"`
|
||
TokensConsumed int `json:"tokens_consumed"`
|
||
}
|
||
|
||
type ChatTokenUsageAdjustPayload struct {
|
||
UserID int `json:"user_id"`
|
||
ConversationID string `json:"conversation_id"`
|
||
TokensDelta int `json:"tokens_delta"`
|
||
Reason string `json:"reason"`
|
||
TriggeredAt time.Time `json:"triggered_at"`
|
||
}
|
||
|
||
type GetConversationMetaResponse struct {
|
||
ConversationID string `json:"conversation_id"`
|
||
Title string `json:"title"`
|
||
HasTitle bool `json:"has_title"`
|
||
MessageCount int `json:"message_count"`
|
||
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
|
||
Status string `json:"status"`
|
||
}
|
||
|
||
type GetConversationListItem struct {
|
||
ConversationID string `json:"conversation_id"`
|
||
Title string `json:"title"`
|
||
HasTitle bool `json:"has_title"`
|
||
MessageCount int `json:"message_count"`
|
||
LastMessageAt *time.Time `json:"last_message_at,omitempty"`
|
||
Status string `json:"status"`
|
||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||
}
|
||
|
||
type GetConversationListResponse struct {
|
||
List []GetConversationListItem `json:"list"`
|
||
Page int `json:"page"`
|
||
PageSize int `json:"page_size"`
|
||
Limit int `json:"limit"`
|
||
Total int64 `json:"total"`
|
||
HasMore bool `json:"has_more"`
|
||
}
|
||
|
||
type GetConversationHistoryItem struct {
|
||
ID int `json:"id,omitempty"`
|
||
Role string `json:"role"`
|
||
Content string `json:"content"`
|
||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||
ReasoningContent string `json:"reasoning_content,omitempty"`
|
||
ReasoningDurationSeconds int `json:"reasoning_duration_seconds,omitempty"`
|
||
}
|
||
|
||
type SchedulePlanPreviewCache struct {
|
||
UserID int `json:"user_id"`
|
||
ConversationID string `json:"conversation_id"`
|
||
TraceID string `json:"trace_id,omitempty"`
|
||
Summary string `json:"summary"`
|
||
CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
|
||
TaskClassIDs []int `json:"task_class_ids,omitempty"`
|
||
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
|
||
AllocatedItems []TaskClassItem `json:"allocated_items,omitempty"`
|
||
GeneratedAt time.Time `json:"generated_at"`
|
||
}
|
||
|
||
type GetSchedulePlanPreviewResponse struct {
|
||
ConversationID string `json:"conversation_id"`
|
||
TraceID string `json:"trace_id,omitempty"`
|
||
Summary string `json:"summary"`
|
||
CandidatePlans []UserWeekSchedule `json:"candidate_plans"`
|
||
HybridEntries []HybridScheduleEntry `json:"hybrid_entries,omitempty"`
|
||
GeneratedAt time.Time `json:"generated_at"`
|
||
}
|
||
|
||
type SSEResponse struct {
|
||
Event string `json:"event"`
|
||
ID int `json:"id,omitempty"`
|
||
Retry int64 `json:"retry,omitempty"`
|
||
Data SSEMessageData `json:"data"`
|
||
}
|
||
|
||
type SSEMessageData struct {
|
||
Step int `json:"step,omitempty"`
|
||
Message string `json:"message,omitempty"`
|
||
}
|
||
|
||
type AgentChat struct {
|
||
ID int64 `gorm:"column:id;primaryKey;autoIncrement;comment:自增ID"`
|
||
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;uniqueIndex:uk_chat_id;comment:会话UUID"`
|
||
UserID int `gorm:"column:user_id;not null;index:idx_user_last,priority:1;index:idx_user_status,priority:1;comment:所属用户ID"`
|
||
Title *string `gorm:"column:title;type:varchar(255);comment:会话标题"`
|
||
SystemPrompt *string `gorm:"column:system_prompt;type:text;comment:系统提示词"`
|
||
Model *string `gorm:"column:model;type:varchar(100);comment:模型标识"`
|
||
MessageCount int `gorm:"column:message_count;not null;default:0;comment:消息总数"`
|
||
TokensTotal int `gorm:"column:tokens_total;not null;default:0;comment:累计Token"`
|
||
LastMessageAt *time.Time `gorm:"column:last_message_at;comment:最后消息时间"`
|
||
Status string `gorm:"column:status;type:varchar(32);not null;default:active;index:idx_user_status,priority:2;comment:会话状态"`
|
||
CompactionSummary *string `gorm:"column:compaction_summary;type:text;comment:历史上下文压缩摘要"`
|
||
CompactionWatermark int `gorm:"column:compaction_watermark;not null;default:0;comment:压缩水位线(最后被压缩的消息ID)"`
|
||
ContextTokenStats *string `gorm:"column:context_token_stats;type:json;comment:上下文窗口实时token分布"`
|
||
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"`
|
||
UpdatedAt *time.Time `gorm:"column:updated_at;autoUpdateTime"`
|
||
DeletedAt *time.Time `gorm:"column:deleted_at;comment:软删除时间"`
|
||
}
|
||
|
||
func (AgentChat) TableName() string { return "agent_chats" }
|
||
|
||
type ChatHistory struct {
|
||
ID int `gorm:"column:id;primaryKey;autoIncrement"`
|
||
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;index:idx_user_chat,priority:2;index:idx_chat_id;comment:会话UUID"`
|
||
UserID int `gorm:"column:user_id;not null;index:idx_user_chat,priority:1"`
|
||
MessageContent *string `gorm:"column:message_content;type:text;comment:消息内容"`
|
||
ReasoningContent *string `gorm:"column:reasoning_content;type:text;comment:deep reasoning text"`
|
||
ReasoningDurationSeconds int `gorm:"column:reasoning_duration_seconds;not null;default:0;comment:deep reasoning duration seconds"`
|
||
RetryGroupID *string `gorm:"column:retry_group_id;type:varchar(64);index:idx_retry_group;comment:retry group id"`
|
||
RetryIndex *int `gorm:"column:retry_index;comment:retry page index"`
|
||
RetryFromUserMessageID *int `gorm:"column:retry_from_user_message_id;comment:source user message id"`
|
||
RetryFromAssistantMessageID *int `gorm:"column:retry_from_assistant_message_id;comment:source assistant message id"`
|
||
Role *string `gorm:"column:role;type:varchar(32);comment:消息角色"`
|
||
TokensConsumed int `gorm:"column:tokens_consumed;not null;default:0;comment:本轮消耗Token"`
|
||
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime"`
|
||
|
||
Chat AgentChat `gorm:"foreignKey:ChatID;references:ChatID;constraint:OnUpdate:CASCADE,OnDelete:CASCADE"`
|
||
}
|
||
|
||
func (ChatHistory) TableName() string { return "chat_histories" }
|