Version: 0.9.48.dev.260428

后端:
1.新增任务批量状态查询能力,补齐入参归一化、单次上限控制、按当前用户隔离与空结果兼容。
2.QuickTask 从纯文本升级为“正文 + business_card”输出,覆盖 task_record/task_query 两类卡片语义。
3.查询链路新增时间窗边界筛选与异常窗口兜底,SSE/timeline 同步扩展 business_card 事件并持久化。

前端:
1.助手面板接入任务状态 hydration 与增量同步,卡片状态可实时联动(完成/撤销、编辑、删除、同步中)。
2.TaskRecord/TaskQuery 卡片升级为可交互任务卡,并新增对话页任务编辑弹窗与回写闭环。
3.助手路由升级为 /assistant/:id?,支持 URL 驱动会话切换与刷新恢复。

仓库:
同步更新 business card 前端对接说明文档。
This commit is contained in:
Losita
2026-04-28 00:32:33 +08:00
parent 20d8f2acae
commit 495d520b20
19 changed files with 1864 additions and 212 deletions

View File

@@ -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 根据截止时间推断默认优先级。