后端: 1. 流式思考链路从 raw reasoning_content 切到 `thinking_summary` 摘要协议,补齐摘要 prompt、digestor 与 Lite 压缩链路,plan / execute / fallback 统一改为“只出摘要、不透原始推理”,正文开始后自动关停摘要流。 2. thinking_summary 打通 timeline / SSE / outbox 持久化闭环,只落 detail_summary 与必要 metadata,并补强 seq 自检、冲突幂等识别与补 seq 回填,提升重放恢复稳定性。 3. 会话历史口径继续收紧,assistant 正文与时间线不再回写 raw reasoning_content,仅保留正文与思考耗时,避免刷新恢复时再次暴露内部推理文本。 前端: 4. 助手页开始接入 thinking_summary 实时流与历史恢复,补齐短摘要状态、长摘要折叠区、正文开流后自动收口,并增加调试入口用于协议联调与验收。 5. 当前前端助手页仍是残次过渡态,本版先以 thinking_summary 协议接通和基础渲染为主,样式、交互与细节体验暂未收平,下一版集中修复。 仓库: 6. 补充 thinking_summary 对接说明,明确 SSE 协议、timeline 恢复口径与 short/detail summary 的使用边界。
148 lines
6.6 KiB
Go
148 lines
6.6 KiB
Go
package model
|
||
|
||
import (
|
||
"strings"
|
||
"time"
|
||
)
|
||
|
||
// AgentTimelineKind 定义会话时间线事件类型。
|
||
//
|
||
// 说明:
|
||
// 1. 这些类型面向前端渲染,要求语义稳定,不随节点内部实现细节频繁变化;
|
||
// 2. 文本消息和卡片事件共用一条时间线,前端只按 seq 顺序渲染;
|
||
// 3. token 统计仍以 chat_histories / agent_chats 为准,时间线只负责展示顺序与结构承载。
|
||
const (
|
||
AgentTimelineKindUserText = "user_text"
|
||
AgentTimelineKindAssistantText = "assistant_text"
|
||
AgentTimelineKindToolCall = "tool_call"
|
||
AgentTimelineKindToolResult = "tool_result"
|
||
AgentTimelineKindConfirmRequest = "confirm_request"
|
||
AgentTimelineKindBusinessCard = "business_card"
|
||
AgentTimelineKindScheduleCompleted = "schedule_completed"
|
||
AgentTimelineKindThinkingSummary = "thinking_summary"
|
||
)
|
||
|
||
// AgentTimelineEvent 表示会话里“可展示事件”的统一持久化记录。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只承载“顺序 + 展示信息”,不替代 chat_histories 的消息账本职责;
|
||
// 2. seq 是同一会话内的单调递增顺序号,用于刷新后重建展示顺序;
|
||
// 3. payload 只保存前端渲染需要的结构化信息,不存整个运行时快照。
|
||
type AgentTimelineEvent struct {
|
||
ID int64 `gorm:"column:id;primaryKey;autoIncrement"`
|
||
UserID int `gorm:"column:user_id;not null;uniqueIndex:uk_timeline_user_chat_seq,priority:1;index:idx_timeline_user_chat_created,priority:1;comment:所属用户ID"`
|
||
ChatID string `gorm:"column:chat_id;type:varchar(36);not null;uniqueIndex:uk_timeline_user_chat_seq,priority:2;index:idx_timeline_user_chat_created,priority:2;comment:会话UUID"`
|
||
Seq int64 `gorm:"column:seq;not null;uniqueIndex:uk_timeline_user_chat_seq,priority:3;comment:会话内顺序号"`
|
||
Kind string `gorm:"column:kind;type:varchar(64);not null;comment:事件类型"`
|
||
Role *string `gorm:"column:role;type:varchar(32);comment:消息角色"`
|
||
Content *string `gorm:"column:content;type:text;comment:正文内容"`
|
||
Payload *string `gorm:"column:payload;type:json;comment:结构化负载"`
|
||
TokensConsumed int `gorm:"column:tokens_consumed;not null;default:0;comment:该事件关联 token,默认 0"`
|
||
CreatedAt *time.Time `gorm:"column:created_at;autoCreateTime;index:idx_timeline_user_chat_created,priority:3"`
|
||
}
|
||
|
||
func (AgentTimelineEvent) TableName() string { return "agent_timeline_events" }
|
||
|
||
// ChatTimelinePersistPayload 定义时间线单条事件落库输入。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只表达一次“写入 agent_timeline_events”的最小字段集合;
|
||
// 2. Content 面向纯文本类事件,结构化事件更多依赖 PayloadJSON;
|
||
// 3. thinking_summary 事件要求 PayloadJSON 内只保留 detail_summary 与必要 metadata。
|
||
type ChatTimelinePersistPayload struct {
|
||
UserID int `json:"user_id"`
|
||
ConversationID string `json:"conversation_id"`
|
||
Seq int64 `json:"seq"`
|
||
Kind string `json:"kind"`
|
||
Role string `json:"role,omitempty"`
|
||
Content string `json:"content,omitempty"`
|
||
PayloadJSON string `json:"payload_json,omitempty"`
|
||
TokensConsumed int `json:"tokens_consumed"`
|
||
}
|
||
|
||
// Normalize 负责收敛时间线持久化载荷的基础口径。
|
||
//
|
||
// 职责边界:
|
||
// 1. 只做字符串 trim 和非负数兜底;
|
||
// 2. 不负责 thinking_summary 的业务裁剪;
|
||
// 3. 返回副本,避免调用方意外修改原对象。
|
||
func (p ChatTimelinePersistPayload) Normalize() ChatTimelinePersistPayload {
|
||
p.ConversationID = strings.TrimSpace(p.ConversationID)
|
||
p.Kind = strings.TrimSpace(p.Kind)
|
||
p.Role = strings.TrimSpace(p.Role)
|
||
p.Content = strings.TrimSpace(p.Content)
|
||
p.PayloadJSON = strings.TrimSpace(p.PayloadJSON)
|
||
if p.Seq < 0 {
|
||
p.Seq = 0
|
||
}
|
||
if p.TokensConsumed < 0 {
|
||
p.TokensConsumed = 0
|
||
}
|
||
return p
|
||
}
|
||
|
||
// HasValidIdentity 判断 payload 是否具备最小可持久化主键语义。
|
||
func (p ChatTimelinePersistPayload) HasValidIdentity() bool {
|
||
normalized := p.Normalize()
|
||
return normalized.UserID > 0 &&
|
||
normalized.ConversationID != "" &&
|
||
normalized.Seq > 0 &&
|
||
normalized.Kind != ""
|
||
}
|
||
|
||
// MatchesStoredEvent 判断 payload 与库中事件是否可视为“同一条业务事件”。
|
||
//
|
||
// 说明:
|
||
// 1. 主要用于 outbox 重放时识别“唯一键冲突但其实已经成功落库”的场景;
|
||
// 2. 只比较持久化字段,不比较 created_at / id 这类存储侧派生值;
|
||
// 3. 返回 true 时,上层可以把 seq 冲突视为幂等成功。
|
||
func (p ChatTimelinePersistPayload) MatchesStoredEvent(event AgentTimelineEvent) bool {
|
||
normalized := p.Normalize()
|
||
return event.UserID == normalized.UserID &&
|
||
strings.TrimSpace(event.ChatID) == normalized.ConversationID &&
|
||
event.Seq == normalized.Seq &&
|
||
strings.TrimSpace(event.Kind) == normalized.Kind &&
|
||
trimTimelinePointerString(event.Role) == normalized.Role &&
|
||
trimTimelinePointerString(event.Content) == normalized.Content &&
|
||
trimTimelinePointerString(event.Payload) == normalized.PayloadJSON &&
|
||
event.TokensConsumed == normalized.TokensConsumed
|
||
}
|
||
|
||
// IsTimelineSeqConflictError 判断 error 是否属于时间线 seq 唯一键冲突。
|
||
//
|
||
// 说明:
|
||
// 1. MySQL / PostgreSQL / SQLite 的重复键报错文案并不完全一致,这里用宽松文本匹配;
|
||
// 2. 该函数只用于“是否进入幂等/补 seq 分支”的判断,不承担精确错误分类职责;
|
||
// 3. 若未来统一抽数据库错误码适配层,应优先替换这里而不是继续复制判断逻辑。
|
||
func IsTimelineSeqConflictError(err error) bool {
|
||
if err == nil {
|
||
return false
|
||
}
|
||
lower := strings.ToLower(err.Error())
|
||
return strings.Contains(lower, "duplicate entry") ||
|
||
strings.Contains(lower, "duplicate key") ||
|
||
strings.Contains(lower, "unique constraint") ||
|
||
strings.Contains(lower, "unique violation") ||
|
||
strings.Contains(lower, "error 1062") ||
|
||
strings.Contains(lower, "uk_timeline_user_chat_seq")
|
||
}
|
||
|
||
// GetConversationTimelineItem 定义前端读取时间线接口的单条返回项。
|
||
type GetConversationTimelineItem struct {
|
||
ID int64 `json:"id,omitempty"`
|
||
Seq int64 `json:"seq"`
|
||
Kind string `json:"kind"`
|
||
Role string `json:"role,omitempty"`
|
||
Content string `json:"content,omitempty"`
|
||
Payload map[string]any `json:"payload,omitempty"`
|
||
TokensConsumed int `json:"tokens_consumed,omitempty"`
|
||
CreatedAt *time.Time `json:"created_at,omitempty"`
|
||
}
|
||
|
||
func trimTimelinePointerString(value *string) string {
|
||
if value == nil {
|
||
return ""
|
||
}
|
||
return strings.TrimSpace(*value)
|
||
}
|