diff --git a/backend/api/task.go b/backend/api/task.go index cd15c3d..1d90c8d 100644 --- a/backend/api/task.go +++ b/backend/api/task.go @@ -63,6 +63,39 @@ func (th *TaskHandler) GetUserTasks(c *gin.Context) { c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) } +// BatchTaskStatus 批量查询当前用户任务的实时完成状态。 +// +// 职责边界: +// 1. 负责解析 ids 与读取鉴权上下文中的 user_id; +// 2. 负责调用 Service 复用任务缓存读取链路; +// 3. 不修改任务、不触发幂等中间件、不反写 NewAgent timeline 历史 payload。 +func (th *TaskHandler) BatchTaskStatus(c *gin.Context) { + // 1. 绑定请求参数。ids 允许为空切片,表示前端当前没有需要 hydration 的任务卡片。 + var req model.BatchTaskStatusRequest + if err := c.ShouldBindJSON(&req); err != nil { + c.JSON(http.StatusBadRequest, respond.WrongParamType) + fmt.Println(err) + return + } + + // 2. 从鉴权上下文读取 user_id,Service 会继续用该 user_id 限定任务集合。 + userID := c.GetInt("user_id") + + // 3. 设置短超时:该接口只读缓存/任务列表,避免异常情况下长时间占用连接。 + ctx, cancel := context.WithTimeout(c.Request.Context(), 1*time.Second) + defer cancel() + + // 4. 调用 Service 做 ID 归一化与当前状态查询。 + resp, err := th.svc.BatchTaskStatus(ctx, &req, userID) + if err != nil { + respond.DealWithError(c, err) + return + } + + // 5. 返回统一响应结构,items 为空时仍按 success 返回,便于前端无分支处理。 + c.JSON(http.StatusOK, respond.RespWithData(respond.Ok, resp)) +} + // CompleteTask 标记任务为已完成。 // // 职责边界: diff --git a/backend/model/agent_timeline.go b/backend/model/agent_timeline.go index 3f097ea..700232f 100644 --- a/backend/model/agent_timeline.go +++ b/backend/model/agent_timeline.go @@ -14,6 +14,7 @@ const ( AgentTimelineKindToolCall = "tool_call" AgentTimelineKindToolResult = "tool_result" AgentTimelineKindConfirmRequest = "confirm_request" + AgentTimelineKindBusinessCard = "business_card" AgentTimelineKindScheduleCompleted = "schedule_completed" ) diff --git a/backend/model/task.go b/backend/model/task.go index 51efd48..f347355 100644 --- a/backend/model/task.go +++ b/backend/model/task.go @@ -113,6 +113,35 @@ type GetUserTaskResp struct { UrgencyThresholdAt string `json:"urgency_threshold_at,omitempty"` } +// BatchTaskStatusRequest 是任务批量状态查询请求体。 +// +// 职责边界: +// 1. 只承载前端从历史卡片中提取的任务 ID 列表; +// 2. 不承载 user_id,用户身份必须来自鉴权上下文,避免越权查询; +// 3. 不表达任务是否必须存在,不存在或无权访问的任务由 Service 静默过滤。 +type BatchTaskStatusRequest struct { + IDs []int `json:"ids"` +} + +// BatchTaskStatusItem 是单个任务当前完成状态快照。 +// +// 说明: +// 1. 当前 Task 模型未维护 UpdatedAt 字段,因此这里只返回可用的 id/is_completed; +// 2. 该结构表示"当前状态",不用于反写 NewAgent timeline 历史 payload。 +type BatchTaskStatusItem struct { + ID int `json:"id"` + IsCompleted bool `json:"is_completed"` +} + +// BatchTaskStatusResponse 是批量任务状态查询响应体。 +// +// 职责边界: +// 1. items 只包含当前登录用户有权访问且仍存在的任务; +// 2. ids 为空、非法 ID 全部被过滤、或无匹配任务时,items 为空切片而不是业务错误。 +type BatchTaskStatusResponse struct { + Items []BatchTaskStatusItem `json:"items"` +} + // UserUpdateTaskRequest 是"更新任务属性"接口的请求体。 // // 职责边界: diff --git a/backend/newAgent/node/quick_task.go b/backend/newAgent/node/quick_task.go index 5a6a1da..5a48d93 100644 --- a/backend/newAgent/node/quick_task.go +++ b/backend/newAgent/node/quick_task.go @@ -18,8 +18,10 @@ import ( ) const ( - quickTaskStageName = "quick_task" - quickTaskBlockID = "qt_main" + quickTaskStageName = "quick_task" + quickTaskBlockID = "qt_main" + quickTaskResultCardID = "quick_task.result" + taskRecordSourceQuickNote = "quick_note" ) // QuickTaskNodeInput 描述快捷任务节点的输入。 @@ -43,14 +45,27 @@ type quickTaskDecision struct { TaskID *int `json:"task_id,omitempty"` // query 参数 - Quadrant *int `json:"quadrant,omitempty"` - Keyword string `json:"keyword,omitempty"` - Limit *int `json:"limit,omitempty"` + Quadrant *int `json:"quadrant,omitempty"` + Keyword string `json:"keyword,omitempty"` + Limit *int `json:"limit,omitempty"` + DeadlineAfter string `json:"deadline_after,omitempty"` + DeadlineBefore string `json:"deadline_before,omitempty"` // ask 参数 Question string `json:"question,omitempty"` } +// quickTaskActionResult 是 quick_task 执行动作后的统一回包。 +// +// 职责边界: +// 1. AssistantText 是本轮要补发给用户的短正文; +// 2. BusinessCard 仅在“业务真实成功”时携带,失败/追问场景必须为空; +// 3. 不负责直接发射,发射时机由 RunQuickTaskNode 统一控制。 +type quickTaskActionResult struct { + AssistantText string + BusinessCard *newagentstream.StreamBusinessCardExtra +} + // RunQuickTaskNode 执行快捷任务节点:流式 LLM 提取意图 → 直接调 service → 追加结果。 func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error { flowState := input.RuntimeState.EnsureCommonState() @@ -158,7 +173,8 @@ func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error { if decision == nil { finalText := fullText.String() if strings.TrimSpace(finalText) == "" { - _ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, "抱歉,处理任务时出了点问题,请重试。", true) + finalText = "抱歉,处理任务时出了点问题,请重试。" + _ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, finalText, true) } msg := schema.AssistantMessage(finalText, nil) input.ConversationContext.AppendHistory(msg) @@ -170,32 +186,52 @@ func RunQuickTaskNode(ctx context.Context, input QuickTaskNodeInput) error { log.Printf("[DEBUG] quick_task: chat=%s action=%s raw_title=%s", flowState.ConversationID, decision.Action, decision.Title) // 5. 根据意图执行操作。 - var resultText string + result := quickTaskActionResult{} switch decision.Action { case "create": - resultText = handleQuickTaskCreate(ctx, input, decision, flowState) + result = handleQuickTaskCreate(ctx, input, decision, flowState) case "query": - resultText = handleQuickTaskQuery(ctx, input, decision, flowState) + result = handleQuickTaskQuery(ctx, input, decision, flowState) case "ask": - resultText = decision.Question - if resultText == "" { - resultText = "你想记录什么呢?告诉我具体内容吧。" + result.AssistantText = decision.Question + if result.AssistantText == "" { + result.AssistantText = "你想记录什么呢?告诉我具体内容吧。" } default: - resultText = "抱歉,我没有理解你的意思。你可以试试说「记一下明天开会」或「看看我的任务」。" + result.AssistantText = "抱歉,我没有理解你的意思。你可以试试说「记一下明天开会」或「看看我的任务」。" } - // 6. 追加操作结果文本。 - if resultText != "" { - _ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, resultText, false) - fullText.WriteString(resultText) + // 6. 追加操作结果正文。 + if result.AssistantText != "" { + _ = emitter.EmitAssistantText(quickTaskBlockID, quickTaskStageName, result.AssistantText, false) + fullText.WriteString(result.AssistantText) } - // 7. 写入对话历史。 - finalText := fullText.String() - msg := schema.AssistantMessage(finalText, nil) - input.ConversationContext.AppendHistory(msg) - persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg) + messagePersisted := false + // 7.1 有业务卡片时,先落正文,再发卡片,保证 timeline 顺序与前端展示一致。 + // 1. 先持久化正文,确保 timeline 里的 assistant_text seq 一定早于 business_card; + // 2. 再发 business_card,保证“短正文 + 紧跟卡片”的时序契约; + // 3. 卡片发射失败只记日志,不回滚正文,避免用户侧出现“看不到结果文本”的回退。 + if result.BusinessCard != nil { + finalText := fullText.String() + if strings.TrimSpace(finalText) != "" { + msg := schema.AssistantMessage(finalText, nil) + input.ConversationContext.AppendHistory(msg) + persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg) + messagePersisted = true + } + if emitErr := emitter.EmitBusinessCard(quickTaskResultCardID, quickTaskStageName, result.BusinessCard); emitErr != nil { + log.Printf("[WARN] quick_task emit business_card error chat=%s err=%v", flowState.ConversationID, emitErr) + } + } + + // 7.2 非卡片路径沿用原有收口:本轮正文统一一次性写入 history。 + if !messagePersisted { + finalText := fullText.String() + msg := schema.AssistantMessage(finalText, nil) + input.ConversationContext.AppendHistory(msg) + persistVisibleAssistantMessage(ctx, input.PersistVisibleMessage, flowState, msg) + } flowState.Phase = newagentmodel.PhaseDone return nil @@ -207,17 +243,18 @@ func handleQuickTaskCreate( input QuickTaskNodeInput, decision *quickTaskDecision, flowState *newagentmodel.CommonState, -) string { +) quickTaskActionResult { + _ = ctx title := strings.TrimSpace(decision.Title) if title == "" { - return "你想记录什么呢?告诉我具体内容吧。" + return quickTaskActionResult{AssistantText: "你想记录什么呢?告诉我具体内容吧。"} } var deadline *time.Time if raw := strings.TrimSpace(decision.DeadlineAt); raw != "" { parsed, err := newagentshared.ParseOptionalDeadline(raw) if err != nil { - return fmt.Sprintf("截止时间格式不太对(%s),不过我先把任务记下来啦。", err) + return quickTaskActionResult{AssistantText: fmt.Sprintf("截止时间格式不太对(%s),不过我先把任务记下来啦。", err)} } deadline = parsed } @@ -245,23 +282,16 @@ func handleQuickTaskCreate( log.Printf("[DEBUG] quick_task: CreateTask 参数 chat=%s title=%s priorityGroup=%d deadline=%v urgencyThreshold=%v urgency_raw=%q", flowState.ConversationID, title, priorityGroup, deadline, urgencyThreshold, decision.UrgencyThresholdAt) - _, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, deadline, urgencyThreshold) + taskID, err := input.QuickTaskDeps.CreateTask(flowState.UserID, title, priorityGroup, deadline, urgencyThreshold) if err != nil { - return fmt.Sprintf("记录失败了(%s),稍后再试试?", err) + return quickTaskActionResult{AssistantText: fmt.Sprintf("记录失败了(%s),稍后再试试?", err)} } flowState.UsedQuickNote = true - - priorityLabel := newagentshared.PriorityLabelCN(priorityGroup) - deadlineStr := "" - if deadline != nil { - deadlineStr = deadline.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04") + return quickTaskActionResult{ + AssistantText: "已帮你记下这条任务。", + BusinessCard: buildTaskRecordBusinessCard(taskID, title, priorityGroup, deadline, urgencyThreshold), } - - if deadlineStr != "" { - return fmt.Sprintf("已记录:%s(%s,截止 %s)", title, priorityLabel, deadlineStr) - } - return fmt.Sprintf("已记录:%s(%s)", title, priorityLabel) } // handleQuickTaskQuery 处理任务查询。 @@ -270,7 +300,7 @@ func handleQuickTaskQuery( input QuickTaskNodeInput, decision *quickTaskDecision, flowState *newagentmodel.CommonState, -) string { +) quickTaskActionResult { params := newagentmodel.TaskQueryParams{ SortBy: "deadline", Order: "asc", @@ -287,32 +317,254 @@ func handleQuickTaskQuery( if decision.Limit != nil && *decision.Limit > 0 && *decision.Limit <= 20 { params.Limit = *decision.Limit } + params.DeadlineAfter = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineAfter, "deadline_after", flowState) + params.DeadlineBefore = parseQuickTaskQueryDeadlineBoundary(decision.DeadlineBefore, "deadline_before", flowState) + // 1. 若模型给出了颠倒的时间窗(before<=after),当前轮降级为“不加时间窗”继续查询; + // 2. 这样能避免误筛选成空结果,同时把异常留给日志排查; + // 3. 这里只做兜底,不尝试替模型自动纠正语义,避免引入额外猜测。 + if params.DeadlineAfter != nil && params.DeadlineBefore != nil && !params.DeadlineBefore.After(*params.DeadlineAfter) { + log.Printf("[WARN] quick_task: query 时间窗无效 chat=%s after=%s before=%s,已降级为无时间窗筛选", + flowState.ConversationID, + formatQuickTaskTime(params.DeadlineAfter), + formatQuickTaskTime(params.DeadlineBefore), + ) + params.DeadlineAfter = nil + params.DeadlineBefore = nil + } results, err := input.QuickTaskDeps.QueryTasks(ctx, flowState.UserID, params) if err != nil { - return fmt.Sprintf("查询失败了(%s),稍后再试试?", err) + return quickTaskActionResult{AssistantText: fmt.Sprintf("查询失败了(%s),稍后再试试?", err)} } + card := buildTaskQueryBusinessCard(params, results) if len(results) == 0 { - return "当前没有匹配的任务。" + return quickTaskActionResult{ + AssistantText: "我这边没查到匹配任务。", + BusinessCard: card, + } } - var sb strings.Builder - sb.WriteString(fmt.Sprintf("找到 %d 条任务:\n", len(results))) - for _, t := range results { - label := newagentshared.PriorityLabelCN(t.PriorityGroup) - line := fmt.Sprintf("- %s(%s", t.Title, label) - if t.DeadlineAt != "" { - line += fmt.Sprintf(",截止 %s", t.DeadlineAt) - } - line += ")" - if t.IsCompleted { - line += " ✅" - } - sb.WriteString(line + "\n") + return quickTaskActionResult{ + AssistantText: fmt.Sprintf("我找到 %d 条任务,整理成卡片给你。", len(results)), + BusinessCard: card, + } +} + +func buildTaskRecordBusinessCard(taskID int, title string, priorityGroup int, deadline *time.Time, urgencyThreshold *time.Time) *newagentstream.StreamBusinessCardExtra { + data := map[string]any{ + "id": taskID, + "title": strings.TrimSpace(title), + "priority_group": priorityGroup, + "priority_label": newagentshared.PriorityLabelCN(priorityGroup), + "status": "todo", + } + if formatted := formatQuickTaskTime(deadline); formatted != "" { + data["deadline_at"] = formatted + } + if formatted := formatQuickTaskTime(urgencyThreshold); formatted != "" { + data["urgency_threshold_at"] = formatted } - return sb.String() + // 说明: + // 1. quick_task 当前只有 action=create,未显式区分“随口记 / 正式创建任务”; + // 2. 仅凭当前 prompt 决策无法稳定判断 source=create_task,会引入误判; + // 3. 本轮按最小安全口径固定为 quick_note,等后续补稳定判别字段再切分。 + return &newagentstream.StreamBusinessCardExtra{ + CardType: "task_record", + Title: "已帮你记下", + Summary: "一条轻量提醒已写入任务系统", + Source: taskRecordSourceQuickNote, + Data: data, + } +} + +func buildTaskQueryBusinessCard(params newagentmodel.TaskQueryParams, results []newagentmodel.TaskQueryResult) *newagentstream.StreamBusinessCardExtra { + taskItems := make([]map[string]any, 0, len(results)) + for _, task := range results { + item := map[string]any{ + "id": task.ID, + "title": strings.TrimSpace(task.Title), + "priority_group": task.PriorityGroup, + "priority_label": newagentshared.PriorityLabelCN(task.PriorityGroup), + "is_completed": task.IsCompleted, + } + if deadline := strings.TrimSpace(task.DeadlineAt); deadline != "" { + item["deadline_at"] = deadline + } + taskItems = append(taskItems, item) + } + + title := fmt.Sprintf("找到 %d 条任务", len(results)) + if len(results) == 0 { + title = "未找到匹配任务" + } + + data := map[string]any{ + "result_count": len(results), + "shown_count": len(results), + "tasks": taskItems, + } + queryFilters := buildTaskQueryFilters(params) + if len(queryFilters) > 0 { + data["query_filters"] = queryFilters + } + querySummary := buildTaskQuerySummary(queryFilters) + if querySummary != "" { + data["query_summary"] = querySummary + } + + return &newagentstream.StreamBusinessCardExtra{ + CardType: "task_query", + Title: title, + Summary: querySummary, + Data: data, + } +} + +// buildTaskQueryFilter 生成查询条件的稳定结构化描述。 +// +// 职责边界: +// 1. key/operator/value 提供前端可依赖的机器语义; +// 2. label/display_text 提供前端可直接展示的中文文案; +// 3. query_summary 只能从 display_text 派生,前端不要再反向解析 summary。 +func buildTaskQueryFilter(key string, label string, value any, operator string, displayText string) map[string]any { + filter := map[string]any{ + "key": key, + "label": label, + "value": value, + "display_text": strings.TrimSpace(displayText), + } + if strings.TrimSpace(operator) != "" { + filter["operator"] = strings.TrimSpace(operator) + } + return filter +} + +func buildTaskQueryFilters(params newagentmodel.TaskQueryParams) []map[string]any { + filters := make([]map[string]any, 0, 6) + if params.Quadrant != nil && *params.Quadrant >= 1 && *params.Quadrant <= 4 { + label := newagentshared.PriorityLabelCN(*params.Quadrant) + filters = append(filters, buildTaskQueryFilter( + "quadrant", + "象限", + *params.Quadrant, + "eq", + fmt.Sprintf("象限:%s", label), + )) + } + if kw := strings.TrimSpace(params.Keyword); kw != "" { + filters = append(filters, buildTaskQueryFilter( + "keyword", + "关键词", + kw, + "contains", + fmt.Sprintf("关键词:%s", kw), + )) + } + if params.DeadlineAfter != nil { + formatted := formatQuickTaskTime(params.DeadlineAfter) + filters = append(filters, buildTaskQueryFilter( + "deadline_after", + "截止起始", + formatted, + "gte", + fmt.Sprintf("截止时间≥%s", formatted), + )) + } + if params.DeadlineBefore != nil { + formatted := formatQuickTaskTime(params.DeadlineBefore) + filters = append(filters, buildTaskQueryFilter( + "deadline_before", + "截止结束", + formatted, + "lt", + fmt.Sprintf("截止时间<%s", formatted), + )) + } + if !params.IncludeCompleted { + filters = append(filters, buildTaskQueryFilter( + "include_completed", + "完成状态", + false, + "eq", + "仅未完成", + )) + } + + sortValue := "deadline_asc" + sortDisplay := "按截止时间升序" + switch strings.ToLower(strings.TrimSpace(params.SortBy)) { + case "priority": + if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" { + sortValue = "priority_desc" + sortDisplay = "按优先级降序" + } else { + sortValue = "priority_asc" + sortDisplay = "按优先级升序" + } + case "id": + if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" { + sortValue = "id_desc" + sortDisplay = "按创建顺序倒序" + } else { + sortValue = "id_asc" + sortDisplay = "按创建顺序正序" + } + default: + if strings.ToLower(strings.TrimSpace(params.Order)) == "desc" { + sortValue = "deadline_desc" + sortDisplay = "按截止时间降序" + } + } + filters = append(filters, buildTaskQueryFilter( + "sort", + "排序", + sortValue, + "eq", + sortDisplay, + )) + return filters +} + +func buildTaskQuerySummary(filters []map[string]any) string { + parts := make([]string, 0, len(filters)) + for _, filter := range filters { + if text, ok := filter["display_text"].(string); ok && strings.TrimSpace(text) != "" { + parts = append(parts, strings.TrimSpace(text)) + } + } + return strings.Join(parts, ";") +} + +// parseQuickTaskQueryDeadlineBoundary 解析 quick_task 查询时间窗边界。 +// +// 职责边界: +// 1. 只负责把 query 的 deadline_after/deadline_before 文本解析成时间; +// 2. 解析失败时仅记录日志并返回 nil,不中断查询主链路; +// 3. 不负责时间窗合法性校验(如 before<=after),该校验由调用方统一处理。 +func parseQuickTaskQueryDeadlineBoundary(raw string, field string, flowState *newagentmodel.CommonState) *time.Time { + value := strings.TrimSpace(raw) + if value == "" { + return nil + } + parsed, err := newagentshared.ParseOptionalDeadline(value) + if err != nil { + chatID := "" + if flowState != nil { + chatID = flowState.ConversationID + } + log.Printf("[WARN] quick_task: query %s 解析失败 chat=%s raw=%q err=%v,已降级为无该筛选条件", field, chatID, value, err) + return nil + } + return parsed +} + +func formatQuickTaskTime(t *time.Time) string { + if t == nil { + return "" + } + return t.In(newagentshared.ShanghaiLocation()).Format("2006-01-02 15:04") } // quickNoteFallbackPriority 根据截止时间推断默认优先级。 diff --git a/backend/newAgent/prompt/chat.go b/backend/newAgent/prompt/chat.go index 1bee0bb..72923b3 100644 --- a/backend/newAgent/prompt/chat.go +++ b/backend/newAgent/prompt/chat.go @@ -14,20 +14,21 @@ const chatRoutingSystemPrompt = ` 路由规则: - direct_reply:纯闲聊、简单问答、轻量生活建议、打招呼、感谢等不需要工具、也不需要长链路思考的请求。控制码后直接输出完整回复。 -- quick_task:用户明确想记录/添加/修改/删除一个待办或提醒(如"记一下""提醒我""帮我记"),或查看/筛选任务列表(如"我有什么任务""待办清单""最近急事")。该路由走轻量快捷路径,延迟低、废话少。控制码后不要输出任何内容。 -- execute:需要用工具处理的日程类请求(查询日程、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。 +- quick_task:用户明确想记录/添加一个待办或提醒(如"记一下""提醒我""帮我记"),或查看/筛选任务列表(如"我有什么任务""待办清单""最近急事""今天/明天/本周有什么事要做")。该路由走轻量快捷路径,延迟低、废话少。控制码后不要输出任何内容。 +- execute:需要用工具处理的日程编排请求(明确查课表/日程块、移动课程、排课等),但不需要先制定计划。控制码后输出简短确认。 - deep_answer:复杂问题但不需要工具(如分析建议、知识解释、方案比较、深度讨论等),需要深度思考后回答。控制码后不要输出任何占位过渡语,后端会直接进入第二次正式回答。 - plan:用户明确要求先制定计划,或涉及多阶段复杂规划。控制码后输出简短确认。 quick_task 判别要点: - 用户明确要"记/添加/提醒"一个待办 → quick_task - 用户要查看/筛选/列出任务清单 → quick_task -- 用户要修改/删除某个任务 → quick_task +- 用户问"今天/明天/本周有什么待办/任务/事情要做"这类时间窗任务查询 → quick_task +- 用户明确在查课表/日程块、排课、移动安排 → execute - 但如果用户同时提了日程排布(如"把明天的课调一下,再记一下周五开会"),混合操作走 execute - 如果信息不足(如"帮我记一下"但没说记什么),走 direct_reply 追问 任务类设计路由要点: -- 普通"创建/修改任务类"默认走 execute(由 execute 负责补字段与写入)。 +- 普通"创建/修改任务类配置(task class)"默认走 execute(由 execute 负责补字段与写入)。 - 仅当用户明确要"补课程学习资料/学习建议/学习路径(需要外部知识)"时,走 plan(后续可使用 web_search)。 - 考试时间、DDL、课程具体时间安排、个人可用时段等时间信息,必须向用户本人确认,不能作为 web 搜索补齐目标。 diff --git a/backend/newAgent/prompt/quick_task.go b/backend/newAgent/prompt/quick_task.go index b8fb341..fe0bcee 100644 --- a/backend/newAgent/prompt/quick_task.go +++ b/backend/newAgent/prompt/quick_task.go @@ -23,7 +23,7 @@ const quickTaskSystemPrompt = ` JSON 字段说明: - action:只能是 create / query / ask - create 时:title 必填,deadline_at 必填,priority_group 必填,范围 1-4;urgency_threshold_at 满足条件时填写,条件在下面 -- query 时:quadrant 可选 1-4,keyword 可选,limit 可选 +- query 时:quadrant 可选 1-4,keyword 可选,limit 可选,deadline_after/deadline_before 可选(用于截止时间窗口筛选) - ask 时:question 必填 规则: @@ -33,6 +33,7 @@ JSON 字段说明: 4. 未提供的可选字段直接省略,不要填 null 或空字符串 5. JSON 中不要包含 speak 字段,给用户看的话放在 标签之后 6. 紧急分界时间,即任务从"重要不紧急"自动轮换到"重要且紧急"的时间点;格式同 deadline_at ——当 priority_group=2 时必填,你必须根据 deadline 自动推算一个合理的紧急分界时间(通常为 deadline 前 24-48 小时),不要等用户提供;priority_group 为 1、3、4 或无截止时间时不要输出此字段 +7. query 里出现相对日期窗口(如"明天有什么事要做")时,优先输出明确边界:deadline_after="明天 00:00",deadline_before="后天 00:00",按 [after, before) 语义筛选 示例: @@ -44,6 +45,9 @@ JSON 字段说明: {"action":"query","limit":5} 我帮你查一下当前的任务。 +{"action":"query","deadline_after":"明天 00:00","deadline_before":"后天 00:00","limit":10} +我帮你查一下明天要做的事。 + {"action":"ask","question":"你想记录什么呢?告诉我具体内容吧。"} 你想记录什么呢?告诉我具体内容吧。` diff --git a/backend/newAgent/stream/emitter.go b/backend/newAgent/stream/emitter.go index d5d0956..28bd61f 100644 --- a/backend/newAgent/stream/emitter.go +++ b/backend/newAgent/stream/emitter.go @@ -358,6 +358,18 @@ func (e *ChunkEmitter) EmitScheduleCompleted(blockID, stage string) error { return e.emitExtraOnly(NewScheduleCompletedExtra(blockID, stage)) } +// EmitBusinessCard 输出一次业务结果卡片事件。 +// +// 协议约束: +// 1. 只走 extra,不附带 content/reasoning; +// 2. card 为空时直接跳过,避免发出缺少关键字段的空卡片。 +func (e *ChunkEmitter) EmitBusinessCard(blockID, stage string, card *StreamBusinessCardExtra) error { + if e == nil || e.emit == nil || card == nil { + return nil + } + return e.emitExtraOnly(NewBusinessCardExtra(blockID, stage, card)) +} + // EmitFinish 统一输出 stop 结束块,并带上 finish extra。 func (e *ChunkEmitter) EmitFinish(blockID, stage string) error { if e == nil || e.emit == nil { diff --git a/backend/newAgent/stream/openai.go b/backend/newAgent/stream/openai.go index a8be3d5..d82842e 100644 --- a/backend/newAgent/stream/openai.go +++ b/backend/newAgent/stream/openai.go @@ -46,6 +46,7 @@ const ( StreamExtraKindToolResult StreamExtraKind = "tool_result" StreamExtraKindConfirm StreamExtraKind = "confirm_request" StreamExtraKindInterrupt StreamExtraKind = "interrupt" + StreamExtraKindBusinessCard StreamExtraKind = "business_card" StreamExtraKindFinish StreamExtraKind = "finish" StreamExtraKindScheduleCompleted StreamExtraKind = "schedule_completed" ) @@ -63,18 +64,19 @@ const ( // // 职责边界: // 1. Kind / Stage / BlockID 提供前端排版和分组所需的最小元信息; -// 2. Status / Tool / Confirm / Interrupt 只存展示层真正需要的摘要,不直接耦合后端完整状态对象; +// 2. Status / Tool / Confirm / Interrupt / BusinessCard 只存展示层真正需要的摘要,不直接耦合后端完整状态对象; // 3. Meta 留给后续做灰度扩展,避免每加一种小字段都要立刻改 DTO 结构。 type OpenAIChunkExtra struct { - Kind StreamExtraKind `json:"kind,omitempty"` - BlockID string `json:"block_id,omitempty"` - Stage string `json:"stage,omitempty"` - DisplayMode StreamDisplayMode `json:"display_mode,omitempty"` - Status *StreamStatusExtra `json:"status,omitempty"` - Tool *StreamToolExtra `json:"tool,omitempty"` - Confirm *StreamConfirmExtra `json:"confirm,omitempty"` - Interrupt *StreamInterruptExtra `json:"interrupt,omitempty"` - Meta map[string]any `json:"meta,omitempty"` + Kind StreamExtraKind `json:"kind,omitempty"` + BlockID string `json:"block_id,omitempty"` + Stage string `json:"stage,omitempty"` + DisplayMode StreamDisplayMode `json:"display_mode,omitempty"` + Status *StreamStatusExtra `json:"status,omitempty"` + Tool *StreamToolExtra `json:"tool,omitempty"` + Confirm *StreamConfirmExtra `json:"confirm,omitempty"` + Interrupt *StreamInterruptExtra `json:"interrupt,omitempty"` + BusinessCard *StreamBusinessCardExtra `json:"business_card,omitempty"` + Meta map[string]any `json:"meta,omitempty"` } // StreamStatusExtra 表示普通阶段状态或提示性事件。 @@ -105,6 +107,20 @@ type StreamInterruptExtra struct { Summary string `json:"summary,omitempty"` } +// StreamBusinessCardExtra 表示一张业务结果卡片。 +// +// 职责边界: +// 1. CardType 只允许前端已约定的卡片类型(task_query/task_record); +// 2. Source 仅在 task_record 时有语义,其他卡片类型可为空; +// 3. Data 承载“可直接渲染的最小快照”,避免前端再二次补拉才能看到结果。 +type StreamBusinessCardExtra struct { + CardType string `json:"card_type,omitempty"` + Title string `json:"title,omitempty"` + Summary string `json:"summary,omitempty"` + Source string `json:"source,omitempty"` + Data map[string]any `json:"data,omitempty"` +} + // ToOpenAIStream 把 Eino message 转成 OpenAI 兼容 chunk。 func ToOpenAIStream(chunk *schema.Message, requestID, modelName string, created int64, includeRole bool) (string, error) { return ToOpenAIStreamWithExtra(chunk, requestID, modelName, created, includeRole, nil) @@ -263,6 +279,17 @@ func NewInterruptExtra(blockID, stage, interactionID, interactionType, summary s } } +// NewBusinessCardExtra 创建“业务结果卡片”事件的 extra。 +func NewBusinessCardExtra(blockID, stage string, businessCard *StreamBusinessCardExtra) *OpenAIChunkExtra { + return &OpenAIChunkExtra{ + Kind: StreamExtraKindBusinessCard, + BlockID: blockID, + Stage: stage, + DisplayMode: StreamDisplayModeCard, + BusinessCard: businessCard, + } +} + // NewScheduleCompletedExtra 创建”排程完毕”卡片事件的 extra。 // // 职责边界: @@ -331,5 +358,6 @@ func hasStreamExtra(extra *OpenAIChunkExtra) bool { extra.Tool != nil || extra.Confirm != nil || extra.Interrupt != nil || + extra.BusinessCard != nil || len(extra.Meta) > 0 } diff --git a/backend/routers/routers.go b/backend/routers/routers.go index d091c0b..77cd13d 100644 --- a/backend/routers/routers.go +++ b/backend/routers/routers.go @@ -58,6 +58,7 @@ func RegisterRouters(handlers *api.ApiHandlers, cache *dao.CacheDAO, userRepo *d taskGroup.PUT("/update", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.UpdateTask) taskGroup.DELETE("/delete", middleware.IdempotencyMiddleware(cache), handlers.TaskHandler.DeleteTask) taskGroup.GET("/get", handlers.TaskHandler.GetUserTasks) + taskGroup.POST("/batch-status", handlers.TaskHandler.BatchTaskStatus) } courseGroup := apiGroup.Group("/course") { diff --git a/backend/service/agentsvc/agent_timeline.go b/backend/service/agentsvc/agent_timeline.go index 7175501..2e0d00b 100644 --- a/backend/service/agentsvc/agent_timeline.go +++ b/backend/service/agentsvc/agent_timeline.go @@ -295,6 +295,7 @@ func canonicalizeTimelineKind(kind string, role string) string { model.AgentTimelineKindToolCall, model.AgentTimelineKindToolResult, model.AgentTimelineKindConfirmRequest, + model.AgentTimelineKindBusinessCard, model.AgentTimelineKindScheduleCompleted: return normalizedKind case "text", "message", "query": @@ -343,6 +344,8 @@ func mapTimelineKindFromStreamExtra(extra *newagentstream.OpenAIChunkExtra) (str return model.AgentTimelineKindToolResult, true case newagentstream.StreamExtraKindConfirm: return model.AgentTimelineKindConfirmRequest, true + case newagentstream.StreamExtraKindBusinessCard: + return model.AgentTimelineKindBusinessCard, true case newagentstream.StreamExtraKindScheduleCompleted: return model.AgentTimelineKindScheduleCompleted, true default: @@ -381,8 +384,27 @@ func buildTimelinePayloadFromStreamExtra(extra *newagentstream.OpenAIChunkExtra) "summary": strings.TrimSpace(extra.Interrupt.Summary), } } + if extra.BusinessCard != nil { + payload["business_card"] = cloneStreamBusinessCard(extra.BusinessCard) + } if len(extra.Meta) > 0 { payload["meta"] = cloneTimelinePayload(extra.Meta) } return payload } + +func cloneStreamBusinessCard(card *newagentstream.StreamBusinessCardExtra) map[string]any { + if card == nil { + return nil + } + cloned := map[string]any{ + "card_type": strings.TrimSpace(card.CardType), + "title": strings.TrimSpace(card.Title), + "summary": strings.TrimSpace(card.Summary), + "source": strings.TrimSpace(card.Source), + } + if len(card.Data) > 0 { + cloned["data"] = cloneTimelinePayload(card.Data) + } + return cloned +} diff --git a/backend/service/task.go b/backend/service/task.go index c6e22b6..f05c7e6 100644 --- a/backend/service/task.go +++ b/backend/service/task.go @@ -18,6 +18,8 @@ import ( ) const ( + // taskBatchStatusMaxIDs 限制批量状态查询的单次任务 ID 数量,避免大请求放大缓存/内存扫描成本。 + taskBatchStatusMaxIDs = 100 // taskUrgencyPromoteDedupeTTL 是"同一任务平移请求"的去重锁有效期。 // // 设计考虑: @@ -175,6 +177,65 @@ func (ts *TaskService) GetUserTasks(ctx context.Context, userID int) ([]model.Ge return conv.ModelToGetUserTasksResp(derivedTasks), nil } +// BatchTaskStatus 批量查询当前登录用户任务的完成状态。 +// +// 职责边界: +// 1. 负责请求 ID 的过滤、去重和数量限制; +// 2. 只返回当前用户有权访问且仍存在的任务,避免泄露其他用户任务状态; +// 3. 复用 getRawUserTasks 的 Redis 任务列表缓存链路,不新增绕过缓存的 DAO 查询; +// 4. 该接口只读,不触发 GORM cache_deleter,也不反向修改 NewAgent timeline 历史快照。 +func (ts *TaskService) BatchTaskStatus(ctx context.Context, req *model.BatchTaskStatusRequest, userID int) (*model.BatchTaskStatusResponse, error) { + resp := &model.BatchTaskStatusResponse{ + Items: []model.BatchTaskStatusItem{}, + } + if userID <= 0 { + return nil, respond.WrongUserID + } + if req == nil { + return resp, nil + } + + // 1. 先把前端传入的历史卡片 task id 做归一化。 + // 1.1 非法 ID 直接过滤,避免无意义匹配; + // 1.2 保留首次出现顺序,方便前端按请求顺序回填; + // 1.3 超过上限时截断,避免单次 hydration 请求放大服务端成本。 + validIDs := compactPositiveUniqueTaskIDsWithLimit(req.IDs, taskBatchStatusMaxIDs) + if len(validIDs) == 0 { + return resp, nil + } + + // 2. 复用原始任务读取链路。 + // 2.1 命中 Redis 时直接读取 smartflow:tasks:{userID}; + // 2.2 未命中时由 getRawUserTasks 回源 DB 并回填缓存; + // 2.3 用户没有任何任务时映射为空 items,符合 hydration 的“无匹配不报错”语义。 + tasks, err := ts.getRawUserTasks(ctx, userID) + if err != nil { + if errors.Is(err, respond.UserTasksEmpty) { + return resp, nil + } + return nil, err + } + + // 3. 在当前用户任务集合内做内存匹配。 + // 3.1 不命中的 ID 可能是已删除、属于其他用户、或历史快照里的旧任务,统一静默过滤; + // 3.2 返回字段只包含当前模型可用的完成状态,避免伪造不存在的 updated_at。 + taskByID := make(map[int]model.Task, len(tasks)) + for _, task := range tasks { + taskByID[task.ID] = task + } + for _, id := range validIDs { + task, exists := taskByID[id] + if !exists { + continue + } + resp.Items = append(resp.Items, model.BatchTaskStatusItem{ + ID: task.ID, + IsCompleted: task.IsCompleted, + }) + } + return resp, nil +} + // GetTasksWithUrgencyPromotion 读取用户任务并应用读时紧急性提升 + 异步落库触发。 // // 统一入口,供前端查询(GetUserTasks)和 LLM 工具查询(QueryTasksForTool)复用。 @@ -353,6 +414,16 @@ func (ts *TaskService) releaseTaskPromoteLocks(lockKeys []string) { // 1. 只做参数清洗; // 2. 不承载业务规则判断。 func compactPositiveUniqueTaskIDs(taskIDs []int) []int { + return compactPositiveUniqueTaskIDsWithLimit(taskIDs, 0) +} + +// compactPositiveUniqueTaskIDsWithLimit 对任务 ID 做"过滤非正数 + 去重 + 可选限量"。 +// +// 职责边界: +// 1. 只做纯参数归一化,不查询任务、不判断权限; +// 2. limit <= 0 表示不限制数量,供既有调用保持原行为; +// 3. 达到 limit 后立即停止扫描,避免超长请求继续消耗 CPU。 +func compactPositiveUniqueTaskIDsWithLimit(taskIDs []int, limit int) []int { seen := make(map[int]struct{}, len(taskIDs)) result := make([]int, 0, len(taskIDs)) for _, taskID := range taskIDs { @@ -364,6 +435,9 @@ func compactPositiveUniqueTaskIDs(taskIDs []int) []int { } seen[taskID] = struct{}{} result = append(result, taskID) + if limit > 0 && len(result) >= limit { + break + } } return result } diff --git a/docs/frontend/newagent_business_card_对接说明.md b/docs/frontend/newagent_business_card_对接说明.md index 475dad4..1817b51 100644 --- a/docs/frontend/newagent_business_card_对接说明.md +++ b/docs/frontend/newagent_business_card_对接说明.md @@ -184,8 +184,27 @@ interface TaskQueryCardTaskItem { is_completed?: boolean } +type TaskQueryFilterOperator = 'eq' | 'contains' | 'gte' | 'lt' + +interface TaskQueryCardFilter { + key: + | 'quadrant' + | 'keyword' + | 'deadline_after' + | 'deadline_before' + | 'include_completed' + | 'sort' + label: string + value: string | number | boolean + operator?: TaskQueryFilterOperator + display_text: string +} + interface TaskQueryCardData { + // 展示摘要:只适合整段展示,不作为前端切分协议。 query_summary?: string + // 稳定结构化筛选条件:前端若要渲染标签/chip,应优先消费此字段。 + query_filters?: TaskQueryCardFilter[] result_count: number shown_count: number has_more?: boolean @@ -212,12 +231,15 @@ interface TaskQueryCardData { 有条件时建议补充: - `query_summary` +- `query_filters` - `priority_label` - `deadline_at` - `is_completed` - `shown_count` - `has_more` +其中 `query_summary` 是给人看的整段摘要,不保证分隔符可解析;前端若要拆成标签,应使用 `query_filters[].display_text` 或根据 `key/operator/value` 自行格式化,禁止按 `;` 切分 `query_summary`。 + ### 5.1.5 降级规则 1. 若只有 `result_count` 无任务列表: @@ -242,6 +264,29 @@ interface TaskQueryCardData { "summary": "按截止时间升序", "data": { "query_summary": "关键词:离散数学;仅未完成;截止时间升序", + "query_filters": [ + { + "key": "keyword", + "label": "关键词", + "value": "离散数学", + "operator": "contains", + "display_text": "关键词:离散数学" + }, + { + "key": "include_completed", + "label": "完成状态", + "value": false, + "operator": "eq", + "display_text": "仅未完成" + }, + { + "key": "sort", + "label": "排序", + "value": "deadline_asc", + "operator": "eq", + "display_text": "按截止时间升序" + } + ], "result_count": 4, "shown_count": 3, "has_more": true, diff --git a/frontend/src/api/schedule_agent.ts b/frontend/src/api/schedule_agent.ts index def7c9e..4001743 100644 --- a/frontend/src/api/schedule_agent.ts +++ b/frontend/src/api/schedule_agent.ts @@ -25,8 +25,23 @@ export interface TaskQueryCardTaskItem { is_completed?: boolean } +export interface TaskQueryCardFilter { + key: + | 'quadrant' + | 'keyword' + | 'deadline_after' + | 'deadline_before' + | 'include_completed' + | 'sort' + label: string + value: string | number | boolean + operator?: 'eq' | 'contains' | 'gte' | 'lt' + display_text: string +} + export interface TaskQueryCardData { query_summary?: string + query_filters?: TaskQueryCardFilter[] result_count: number shown_count: number has_more?: boolean diff --git a/frontend/src/api/task.ts b/frontend/src/api/task.ts index fde2086..4e803ca 100644 --- a/frontend/src/api/task.ts +++ b/frontend/src/api/task.ts @@ -77,6 +77,26 @@ export async function updateTask(payload: TaskUpdatePayload) { } } +export interface TaskBatchStatusItem { + id: number + is_completed: boolean +} + +export interface TaskBatchStatusResult { + items: TaskBatchStatusItem[] +} + +export async function getTaskBatchStatus(ids: number[]) { + if (ids.length === 0) return [] + try { + const response = await http.post>('/task/batch-status', { ids }) + return response.data.data?.items ?? [] + } catch (error) { + console.error('Failed to fetch batch status:', error) + return [] + } +} + export async function deleteTask(taskId: number) { try { const response = await http.delete>( @@ -93,3 +113,4 @@ export async function deleteTask(taskId: number) { throw new Error(extractErrorMessage(error, '删除任务失败,请稍后重试')) } } + diff --git a/frontend/src/components/assistant/cards/TaskQueryResultCard.vue b/frontend/src/components/assistant/cards/TaskQueryResultCard.vue index 673383a..c5fd4d9 100644 --- a/frontend/src/components/assistant/cards/TaskQueryResultCard.vue +++ b/frontend/src/components/assistant/cards/TaskQueryResultCard.vue @@ -1,4 +1,5 @@