Version: 0.7.6.dev.260325
后端: - ♻️ 将 `taskquery` 模块迁移至 `agent2`,并完成与 `agent2` 业务链路及整体结构的正式接入 前端: - 🧱 已完成基础框架搭建,并完成了登录、注册、主页等页面并对接了对应接口;但整体功能实现仍在完善中
This commit is contained in:
130
backend/service/agentsvc/agent_history.go
Normal file
130
backend/service/agentsvc/agent_history.go
Normal file
@@ -0,0 +1,130 @@
|
||||
package agentsvc
|
||||
|
||||
import (
|
||||
"context"
|
||||
"log"
|
||||
"strings"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/conv"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/pkg"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/cloudwego/eino/schema"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
// GetConversationHistory 返回指定会话的聊天历史。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责会话 ID 归一化、会话归属校验,以及“先 Redis、后 DB”的读取编排;
|
||||
// 2. 负责把缓存消息 / DB 记录统一转换为 API 响应 DTO;
|
||||
// 3. 不负责补写会话标题,也不负责修改聊天主链路的缓存写入策略。
|
||||
func (s *AgentService) GetConversationHistory(ctx context.Context, userID int, chatID string) ([]model.GetConversationHistoryItem, error) {
|
||||
normalizedChatID := strings.TrimSpace(chatID)
|
||||
if normalizedChatID == "" {
|
||||
return nil, respond.MissingParam
|
||||
}
|
||||
|
||||
// 1. 先做归属校验:
|
||||
// 1.1 Redis 历史缓存只按 chat_id 分桶,不能单靠缓存判断用户归属;
|
||||
// 1.2 因此先查会话是否属于当前用户,避免命中别人会话缓存时产生越权读取;
|
||||
// 1.3 若会话不存在,统一返回 gorm.ErrRecordNotFound,交由 API 层映射为参数错误。
|
||||
exists, err := s.repo.IfChatExists(ctx, userID, normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if !exists {
|
||||
return nil, gorm.ErrRecordNotFound
|
||||
}
|
||||
|
||||
// 2. 优先读 Redis:
|
||||
// 2.1 命中时直接返回,复用当前聊天主链路维护的最近消息窗口;
|
||||
// 2.2 失败策略:缓存读取异常只记日志并继续回源 DB,避免缓存抖动导致接口不可用;
|
||||
// 2.3 注意:缓存消息不包含稳定的 DB 主键与创建时间,因此这些字段允许为空。
|
||||
if s.agentCache != nil {
|
||||
history, cacheErr := s.agentCache.GetHistory(ctx, normalizedChatID)
|
||||
if cacheErr != nil {
|
||||
log.Printf("读取会话历史缓存失败 chat_id=%s: %v", normalizedChatID, cacheErr)
|
||||
} else if history != nil {
|
||||
return buildConversationHistoryItemsFromCache(history), nil
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Redis 未命中时回源 DB:
|
||||
// 3.1 复用现有 GetUserChatHistories 读取最近 N 条历史,保证查询链路和主聊天链路口径一致;
|
||||
// 3.2 失败时直接上抛,由 API 层统一处理;
|
||||
// 3.3 成功后若缓存可用,则顺手回填 Redis,降低后续冷启动成本。
|
||||
histories, err := s.repo.GetUserChatHistories(ctx, userID, pkg.HistoryFetchLimitByModel("worker"), normalizedChatID)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
|
||||
if s.agentCache != nil {
|
||||
if setErr := s.agentCache.BackfillHistory(ctx, normalizedChatID, conv.ToEinoMessages(histories)); setErr != nil {
|
||||
log.Printf("回填会话历史缓存失败 chat_id=%s: %v", normalizedChatID, setErr)
|
||||
}
|
||||
}
|
||||
|
||||
return buildConversationHistoryItemsFromDB(histories), nil
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromCache 把 Redis 中的 Eino 消息转换为接口响应。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只做字段映射,不做权限校验或排序调整;
|
||||
// 2. 不补 created_at/id,因为当前缓存模型不承载这两个字段;
|
||||
// 3. role 统一输出为 user / assistant / system,避免前端再感知 schema.RoleType。
|
||||
func buildConversationHistoryItemsFromCache(messages []*schema.Message) []model.GetConversationHistoryItem {
|
||||
items := make([]model.GetConversationHistoryItem, 0, len(messages))
|
||||
for _, msg := range messages {
|
||||
if msg == nil {
|
||||
continue
|
||||
}
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
Role: normalizeConversationHistoryRole(string(msg.Role)),
|
||||
Content: strings.TrimSpace(msg.Content),
|
||||
ReasoningContent: strings.TrimSpace(msg.ReasoningContent),
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
// buildConversationHistoryItemsFromDB 把数据库聊天记录转换为接口响应。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 只透传 DB 已有字段,不尝试补算 reasoning_content;
|
||||
// 2. message_content / role 为空时兜底为空串与 system,避免空指针影响接口;
|
||||
// 3. 保持 DAO 返回的时间正序,前端可直接渲染。
|
||||
func buildConversationHistoryItemsFromDB(histories []model.ChatHistory) []model.GetConversationHistoryItem {
|
||||
items := make([]model.GetConversationHistoryItem, 0, len(histories))
|
||||
for _, history := range histories {
|
||||
content := ""
|
||||
if history.MessageContent != nil {
|
||||
content = strings.TrimSpace(*history.MessageContent)
|
||||
}
|
||||
|
||||
role := "system"
|
||||
if history.Role != nil {
|
||||
role = normalizeConversationHistoryRole(*history.Role)
|
||||
}
|
||||
|
||||
items = append(items, model.GetConversationHistoryItem{
|
||||
ID: history.ID,
|
||||
Role: role,
|
||||
Content: content,
|
||||
CreatedAt: history.CreatedAt,
|
||||
})
|
||||
}
|
||||
return items
|
||||
}
|
||||
|
||||
func normalizeConversationHistoryRole(role string) string {
|
||||
switch strings.ToLower(strings.TrimSpace(role)) {
|
||||
case "user":
|
||||
return "user"
|
||||
case "assistant":
|
||||
return "assistant"
|
||||
default:
|
||||
return "system"
|
||||
}
|
||||
}
|
||||
@@ -122,6 +122,7 @@ func (s *AgentService) GetConversationList(ctx context.Context, userID, page, pa
|
||||
List: items,
|
||||
Page: normalizedPage,
|
||||
PageSize: normalizedPageSize,
|
||||
Limit: normalizedPageSize,
|
||||
Total: total,
|
||||
HasMore: hasMore,
|
||||
}, nil
|
||||
|
||||
@@ -7,18 +7,14 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/LoveLosita/smartflow/backend/agent/taskquery"
|
||||
agentgraph "github.com/LoveLosita/smartflow/backend/agent2/graph"
|
||||
agentmodel "github.com/LoveLosita/smartflow/backend/agent2/model"
|
||||
agentnode "github.com/LoveLosita/smartflow/backend/agent2/node"
|
||||
"github.com/LoveLosita/smartflow/backend/model"
|
||||
"github.com/LoveLosita/smartflow/backend/respond"
|
||||
"github.com/cloudwego/eino-ext/components/model/ark"
|
||||
)
|
||||
|
||||
// runTaskQueryFlow 执行“任务查询”分支。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责把本次请求接入 taskquery 执行器;
|
||||
// 2. 负责把 user_id 注入工具依赖,确保模型无法越权查他人任务;
|
||||
// 3. 不负责聊天持久化(由 AgentChat 主流程统一收口)。
|
||||
func (s *AgentService) runTaskQueryFlow(
|
||||
ctx context.Context,
|
||||
selectedModel *ark.ChatModel,
|
||||
@@ -26,7 +22,6 @@ func (s *AgentService) runTaskQueryFlow(
|
||||
userID int,
|
||||
emitStage func(stage, detail string),
|
||||
) (string, error) {
|
||||
// 1. 依赖预检:任务查询必须依赖 taskRepo + model。
|
||||
if s == nil || s.taskRepo == nil {
|
||||
return "", errors.New("task query service dependency is not ready")
|
||||
}
|
||||
@@ -34,19 +29,14 @@ func (s *AgentService) runTaskQueryFlow(
|
||||
return "", errors.New("task query model is nil")
|
||||
}
|
||||
|
||||
// 2. 构建执行输入并启动 tool-calling。
|
||||
// 2.1 RequestNow 仅用于 prompt 辅助,不参与数据库过滤。
|
||||
requestNow := time.Now().In(time.Local).Format("2006-01-02 15:04")
|
||||
return taskquery.RunTaskQueryGraph(ctx, taskquery.QueryGraphRunInput{
|
||||
Model: selectedModel,
|
||||
UserMessage: userMessage,
|
||||
RequestNowText: requestNow,
|
||||
MaxReflectRetry: 2,
|
||||
EmitStage: emitStage,
|
||||
Deps: taskquery.TaskQueryToolDeps{
|
||||
QueryTasks: func(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
|
||||
// 2.2 调用目的:在工具层做完参数校验后,这里把 user_id 强制注入,再执行真实查询。
|
||||
// 这样可以保证模型永远只能查当前登录用户的数据。
|
||||
state := agentmodel.NewTaskQueryState(strings.TrimSpace(userMessage), requestNow, agentmodel.DefaultTaskQueryReflectRetry)
|
||||
return agentgraph.RunTaskQueryGraph(ctx, agentnode.TaskQueryGraphRunInput{
|
||||
Model: selectedModel,
|
||||
State: state,
|
||||
EmitStage: emitStage,
|
||||
Deps: agentnode.TaskQueryToolDeps{
|
||||
QueryTasks: func(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
|
||||
req.UserID = userID
|
||||
return s.queryTasksForAgent(ctx, req)
|
||||
},
|
||||
@@ -54,16 +44,8 @@ func (s *AgentService) runTaskQueryFlow(
|
||||
})
|
||||
}
|
||||
|
||||
// queryTasksForAgent 在 Agent 任务查询场景下读取并筛选任务。
|
||||
//
|
||||
// 职责边界:
|
||||
// 1. 负责“读取原始任务 + 读时优先级派生 + 条件筛选 + 排序 + 截断”;
|
||||
// 2. 不负责写库,不触发 outbox(只读查询链路);
|
||||
// 3. 返回的是工具层结构,不直接暴露 DAO 模型给上层。
|
||||
func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.TaskQueryRequest) ([]taskquery.TaskRecord, error) {
|
||||
func (s *AgentService) queryTasksForAgent(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) {
|
||||
_ = ctx
|
||||
|
||||
// 1. 基础参数校验。
|
||||
if req.UserID <= 0 {
|
||||
return nil, errors.New("invalid user_id in task query")
|
||||
}
|
||||
@@ -71,20 +53,14 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas
|
||||
return nil, errors.New("task repository is nil")
|
||||
}
|
||||
|
||||
// 2. 读取用户全部任务。
|
||||
// 2.1 当前 TaskDAO 读取接口无 context 参数,这里保持最小侵入复用既有能力;
|
||||
// 2.2 若用户任务为空,返回空切片而不是 error,方便模型自然回复“暂无任务”。
|
||||
tasks, err := s.taskRepo.GetTasksByUserID(req.UserID)
|
||||
if err != nil {
|
||||
if errors.Is(err, respond.UserTasksEmpty) {
|
||||
return make([]taskquery.TaskRecord, 0), nil
|
||||
return make([]agentnode.TaskQueryTaskRecord, 0), nil
|
||||
}
|
||||
return nil, err
|
||||
}
|
||||
|
||||
// 3. 读时派生 + 条件筛选:
|
||||
// 3.1 先按“紧急分界线”做内存派生,保证查询视图与主业务口径一致;
|
||||
// 3.2 再应用 include_completed/quadrant/keyword/deadline 条件。
|
||||
now := time.Now()
|
||||
filtered := make([]model.Task, 0, len(tasks))
|
||||
for _, originalTask := range tasks {
|
||||
@@ -96,18 +72,14 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas
|
||||
filtered = append(filtered, currentTask)
|
||||
}
|
||||
|
||||
// 4. 排序与截断:
|
||||
// 4.1 排序字段/方向已经在工具层校验过,这里按约定执行;
|
||||
// 4.2 limit 截断只发生在排序之后,保证“前 N 条”语义正确。
|
||||
sortTasksForQuery(filtered, req)
|
||||
if req.Limit > 0 && len(filtered) > req.Limit {
|
||||
filtered = filtered[:req.Limit]
|
||||
}
|
||||
|
||||
// 5. 映射成工具输出结构。
|
||||
records := make([]taskquery.TaskRecord, 0, len(filtered))
|
||||
records := make([]agentnode.TaskQueryTaskRecord, 0, len(filtered))
|
||||
for _, task := range filtered {
|
||||
records = append(records, taskquery.TaskRecord{
|
||||
records = append(records, agentnode.TaskQueryTaskRecord{
|
||||
ID: task.ID,
|
||||
Title: task.Title,
|
||||
PriorityGroup: task.Priority,
|
||||
@@ -119,24 +91,13 @@ func (s *AgentService) queryTasksForAgent(ctx context.Context, req taskquery.Tas
|
||||
return records, nil
|
||||
}
|
||||
|
||||
// applyReadTimeUrgencyPromotion 复用“读时紧急性派生”口径(内存态)。
|
||||
//
|
||||
// 规则:
|
||||
// 1. 已完成任务不派生;
|
||||
// 2. 未到紧急分界线不派生;
|
||||
// 3. 到线后仅做 2->1、4->3 的象限平移;
|
||||
// 4. 只改内存对象,不改数据库。
|
||||
func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
|
||||
if task == nil {
|
||||
return
|
||||
}
|
||||
if task.IsCompleted || task.UrgencyThresholdAt == nil {
|
||||
if task == nil || task.IsCompleted || task.UrgencyThresholdAt == nil {
|
||||
return
|
||||
}
|
||||
if task.UrgencyThresholdAt.After(now) {
|
||||
return
|
||||
}
|
||||
|
||||
switch task.Priority {
|
||||
case 2:
|
||||
task.Priority = 1
|
||||
@@ -145,29 +106,17 @@ func applyReadTimeUrgencyPromotion(task *model.Task, now time.Time) {
|
||||
}
|
||||
}
|
||||
|
||||
// taskMatchesQueryFilter 判断任务是否满足查询条件。
|
||||
func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) bool {
|
||||
// 1. include_completed=false 时默认过滤掉已完成任务。
|
||||
func taskMatchesQueryFilter(task model.Task, req agentnode.TaskQueryRequest) bool {
|
||||
if !req.IncludeCompleted && task.IsCompleted {
|
||||
return false
|
||||
}
|
||||
|
||||
// 2. quadrant 过滤:只保留指定象限。
|
||||
if req.Quadrant != nil && task.Priority != *req.Quadrant {
|
||||
return false
|
||||
}
|
||||
|
||||
// 3. keyword 过滤:对标题做大小写不敏感包含匹配。
|
||||
keyword := strings.TrimSpace(req.Keyword)
|
||||
if keyword != "" {
|
||||
if !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
|
||||
return false
|
||||
}
|
||||
if keyword != "" && !strings.Contains(strings.ToLower(task.Title), strings.ToLower(keyword)) {
|
||||
return false
|
||||
}
|
||||
|
||||
// 4. deadline 区间过滤:
|
||||
// 4.1 只要设置了上下界,deadline_at 为空的任务默认不匹配;
|
||||
// 4.2 区间边界为闭区间(>= after 且 <= before)。
|
||||
if req.DeadlineAfter != nil {
|
||||
if task.DeadlineAt == nil || task.DeadlineAt.Before(*req.DeadlineAfter) {
|
||||
return false
|
||||
@@ -181,17 +130,10 @@ func taskMatchesQueryFilter(task model.Task, req taskquery.TaskQueryRequest) boo
|
||||
return true
|
||||
}
|
||||
|
||||
// sortTasksForQuery 按查询条件排序任务。
|
||||
//
|
||||
// 排序策略:
|
||||
// 1. sort_by=deadline:按截止时间排,deadline 为空的任务统一放末尾;
|
||||
// 2. sort_by=priority:按象限数值排(1 最紧急),同优先级再按 id 倒序;
|
||||
// 3. sort_by=id:按 id 排(可近似“新旧顺序”)。
|
||||
func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
func sortTasksForQuery(tasks []model.Task, req agentnode.TaskQueryRequest) {
|
||||
if len(tasks) <= 1 {
|
||||
return
|
||||
}
|
||||
|
||||
order := strings.ToLower(strings.TrimSpace(req.Order))
|
||||
if order != "desc" {
|
||||
order = "asc"
|
||||
@@ -204,7 +146,6 @@ func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
sort.SliceStable(tasks, func(i, j int) bool {
|
||||
left := tasks[i]
|
||||
right := tasks[j]
|
||||
|
||||
switch sortBy {
|
||||
case "priority":
|
||||
if left.Priority != right.Priority {
|
||||
@@ -213,42 +154,31 @@ func sortTasksForQuery(tasks []model.Task, req taskquery.TaskQueryRequest) {
|
||||
}
|
||||
return left.Priority < right.Priority
|
||||
}
|
||||
// 同优先级时按 id 倒序,保证排序稳定且更接近“最近创建在前”。
|
||||
return left.ID > right.ID
|
||||
case "id":
|
||||
if order == "desc" {
|
||||
return left.ID > right.ID
|
||||
}
|
||||
return left.ID < right.ID
|
||||
default: // deadline
|
||||
default:
|
||||
if less, decided := compareDeadline(left.DeadlineAt, right.DeadlineAt, order); decided {
|
||||
return less
|
||||
}
|
||||
// 截止时间相同或都为空时,回退 id 倒序保证稳定性。
|
||||
return left.ID > right.ID
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
// compareDeadline 比较两个可选截止时间。
|
||||
//
|
||||
// 返回语义:
|
||||
// 1. less:left 是否应排在 right 前;
|
||||
// 2. decided:本次比较是否已能得出顺序;false 表示需要上层继续用次级键比较。
|
||||
func compareDeadline(left, right *time.Time, order string) (less bool, decided bool) {
|
||||
// 1. 都为空:本次不决策,交给次级键。
|
||||
if left == nil && right == nil {
|
||||
return false, false
|
||||
}
|
||||
// 2. 只有一边为空:为空的一侧统一放末尾。
|
||||
if left == nil && right != nil {
|
||||
return false, true
|
||||
}
|
||||
if left != nil && right == nil {
|
||||
return true, true
|
||||
}
|
||||
|
||||
// 3. 两边都不为空:按 order 做时间比较。
|
||||
if left.Equal(*right) {
|
||||
return false, false
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user