后端: 1. ChatNode 路由从 GenerateJSON 重构为流式控制码路由 - 新建 backend/newAgent/router/chat_route.go:流式增量控制码解析器 StreamRouteParser,复用 agent 的 <SMARTFLOW_ROUTE> 正则模式 - 更新 backend/newAgent/node/chat.go:RunChatNode 从 GenerateJSON(阻塞等完整 JSON)改为 Stream + 控制码解析 + 分支流式处理 - streamAndDispatch 核心循环:逐 chunk 喂解析器,控制码解析后按 route 分发 - handleDirectReplyStream:thinking=false 同一流续传,thinking=true 关流后二次 thinking 调用 - handleDeepAnswerStream:移除"让我想想"过渡语,直接关流后发起第二次流式调用(thinking 由 effectiveThinking 控制) - handleRouteExecuteStream / handleRoutePlanStream:关流 → 推送 status → 设 Phase - 更新 backend/newAgent/prompt/chat.go:路由 prompt 从 JSON 格式改为控制码标签格式 - 更新 backend/newAgent/model/chat_contract.go:ChatRoutingDecision 新增 Thinking / Raw 字段,移除 Speak / Reason 2. Thinking 参数从 bool 扩展为 string 三态 - 更新 backend/model/agent.go:UserSendMessageRequest.Thinking 从 bool 改为 string - 更新 backend/service/agentsvc/agent.go:AgentChat / runNormalChatFlow 适配 string 类型,新增 thinkingModeToBool 兼容旧链路 - 更新 backend/service/agentsvc/agent_newagent.go:runNewAgentGraph 接收 thinkingMode string 并注入 CommonState 3. CommonState 新增 ThinkingMode / ExecuteThinking 字段 - 更新 backend/newAgent/model/common_state.go:ThinkingMode 控制下游 thinking 行为("true" 强开 / "false" 强关 / "auto"交路由决策) - ChatNode 通过 resolveEffectiveThinking 合并前端偏好与路由决策,传递给所有下游处理函数 4. 新增真流式推送方法 - 更新 backend/newAgent/stream/emitter.go:新增 EmitStreamAssistantText / EmitStreamReasoningText,桥接 StreamReader → SSE chunk 前端:无 仓库:无
309 lines
12 KiB
Go
309 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"`
|
||
RetryGroupID *string `json:"retry_group_id,omitempty"`
|
||
RetryIndex *int `json:"retry_index,omitempty"`
|
||
RetryFromUserMessageID *int `json:"retry_from_user_message_id,omitempty"`
|
||
RetryFromAssistantMessageID *int `json:"retry_from_assistant_message_id,omitempty"`
|
||
TokensConsumed int `json:"tokens_consumed"`
|
||
}
|
||
|
||
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"`
|
||
RetryGroupID *string `json:"retry_group_id"`
|
||
RetryIndex *int `json:"retry_index"`
|
||
RetryTotal *int `json:"retry_total"`
|
||
}
|
||
|
||
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:会话状态"`
|
||
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" }
|