From a5d301ceb942680a2f6b667973717cada80ca3a1 Mon Sep 17 00:00:00 2001 From: Losita <2810873701@qq.com> Date: Sat, 18 Apr 2026 13:32:26 +0800 Subject: [PATCH] =?UTF-8?q?Version:=200.9.28.dev.260418=20=E5=90=8E?= =?UTF-8?q?=E7=AB=AF=EF=BC=9A=201.=20=E6=9F=A5=E4=BB=BB=E5=8A=A1=E5=8A=9F?= =?UTF-8?q?=E8=83=BD=EF=BC=88query=5Ftasks=EF=BC=89=E4=BB=8E=E6=97=A7=20Ag?= =?UTF-8?q?ent=20=E9=93=BE=E8=B7=AF=E8=BF=81=E7=A7=BB=E4=B8=BA=E6=96=B0=20?= =?UTF-8?q?execute=20=E5=B7=A5=E5=85=B7=20-=20=E6=96=B0=E5=A2=9E=20newAgen?= =?UTF-8?q?t/tools/taskquery.go=EF=BC=9A=E8=87=AA=E5=8C=85=E5=90=AB=20Task?= =?UTF-8?q?QueryToolHandler=EF=BC=8C=E9=9B=B6=E5=BC=95=E7=94=A8=E6=97=A7?= =?UTF-8?q?=20agent=20=E5=8C=85=EF=BC=9B=E5=8F=82=E6=95=B0=E6=A0=A1?= =?UTF-8?q?=E9=AA=8C=EF=BC=88=E8=B1=A1=E9=99=901~4=E3=80=81=E6=8E=92?= =?UTF-8?q?=E5=BA=8F=E7=99=BD=E5=90=8D=E5=8D=95=E3=80=81limit=E4=B8=8A?= =?UTF-8?q?=E9=99=9020=EF=BC=89=E3=80=81=E6=97=B6=E9=97=B4=E8=BE=B9?= =?UTF-8?q?=E7=95=8C=E8=A7=A3=E6=9E=90=EF=BC=88=E5=9B=9B=E7=A7=8D=E6=A0=BC?= =?UTF-8?q?=E5=BC=8F=E8=87=AA=E5=8A=A8=E8=A1=A5=E9=BD=90=EF=BC=89=E3=80=81?= =?UTF-8?q?=E7=BB=93=E6=9E=84=E5=8C=96=20JSON=20=E7=BB=93=E6=9E=9C=20-=20n?= =?UTF-8?q?ewAgent/tools/registry.go=EF=BC=9ADefaultRegistryDeps=20?= =?UTF-8?q?=E6=96=B0=E5=A2=9E=20TaskQuery=20=E5=AD=97=E6=AE=B5=EF=BC=9Bsch?= =?UTF-8?q?eduleFreeTools=20=E6=96=B0=E5=A2=9E=20query=5Ftasks=EF=BC=9B?= =?UTF-8?q?=E6=B3=A8=E5=86=8C=20query=5Ftasks=20=E8=AF=BB=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=EF=BC=88=E6=97=A0=E9=9C=80=20confirm=EF=BC=8C?= =?UTF-8?q?=E4=B8=8D=E4=BE=9D=E8=B5=96=20ScheduleState=EF=BC=89=20-=20newA?= =?UTF-8?q?gent/prompt/execute.go=EF=BC=9A=E6=9C=89=20plan=20/=20ReAct=20?= =?UTF-8?q?=E4=B8=A4=E5=A5=97=E7=B3=BB=E7=BB=9F=20prompt=20=E6=89=A7?= =?UTF-8?q?=E8=A1=8C=E8=A7=84=E5=88=99=E6=96=B0=E5=A2=9E=20query=5Ftasks?= =?UTF-8?q?=20=E8=AF=BB=E6=93=8D=E4=BD=9C=E8=AF=B4=E6=98=8E=EF=BC=8C?= =?UTF-8?q?=E6=94=AF=E6=8C=81=E6=8C=89=E8=B1=A1=E9=99=90=E3=80=81=E5=85=B3?= =?UTF-8?q?=E9=94=AE=E8=AF=8D=E3=80=81=E6=88=AA=E6=AD=A2=E6=97=B6=E9=97=B4?= =?UTF-8?q?=E7=AD=9B=E9=80=89=E6=8E=92=E5=BA=8F=20-=20service/agentsvc/age?= =?UTF-8?q?nt=5Ftask=5Fquery.go=EF=BC=9AqueryTasksForAgent=20=E5=AF=BC?= =?UTF-8?q?=E5=87=BA=E4=B8=BA=20QueryTasksForTool=EF=BC=8C=E4=BE=9B?= =?UTF-8?q?=E5=90=AF=E5=8A=A8=E5=B1=82=E9=97=AD=E5=8C=85=E8=B0=83=E7=94=A8?= =?UTF-8?q?=EF=BC=9B=E5=86=85=E9=83=A8=E8=B0=83=E7=94=A8=E5=90=8C=E6=AD=A5?= =?UTF-8?q?=E6=94=B9=E4=B8=BA=20QueryTasksForTool=20-=20cmd/start.go?= =?UTF-8?q?=EF=BC=9ANewDefaultRegistryWithDeps=20=E6=B3=A8=E5=85=A5=20Task?= =?UTF-8?q?Query=20=E9=97=AD=E5=8C=85=EF=BC=8C=E6=A1=A5=E6=8E=A5=E6=96=B0?= =?UTF-8?q?=E5=B7=A5=E5=85=B7=E5=8F=82=E6=95=B0=E5=88=B0=E6=97=A7=20servic?= =?UTF-8?q?e=20=E5=B1=82=E6=9F=A5=E8=AF=A2=E8=83=BD=E5=8A=9B=EF=BC=8C?= =?UTF-8?q?=E5=A4=8D=E7=94=A8=E5=B7=B2=E6=9C=89=E8=BF=87=E6=BB=A4/?= =?UTF-8?q?=E6=8E=92=E5=BA=8F/=E7=B4=A7=E6=80=A5=E5=BA=A6=E6=8F=90?= =?UTF-8?q?=E5=8D=87=E9=80=BB=E8=BE=91=EF=BC=9B=E6=97=A7=E9=93=BE=E8=B7=AF?= =?UTF-8?q?=E5=85=A8=E9=83=A8=E4=BF=9D=E7=95=99=E4=B8=8D=E5=8A=A8=202.=20o?= =?UTF-8?q?rder=5Fguard=20=E6=9D=A1=E4=BB=B6=E8=A7=A6=E5=8F=91=E2=80=94?= =?UTF-8?q?=E2=80=94=E4=BB=85=E6=97=A5=E7=A8=8B=E5=86=99=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=E5=90=8E=E8=B5=B0=E5=AE=88=E5=8D=AB=E8=8A=82=E7=82=B9=20-=20ne?= =?UTF-8?q?wAgent/model/common=5Fstate.go=EF=BC=9A=E6=96=B0=E5=A2=9E=20Has?= =?UTF-8?q?ScheduleWriteOps=20=E6=A0=87=E8=AE=B0=E5=AD=97=E6=AE=B5?= =?UTF-8?q?=EF=BC=9BResetForNextRun=20=E8=BF=BD=E5=8A=A0=E6=B8=85=E7=90=86?= =?UTF-8?q?=20-=20newAgent/node/execute.go=EF=BC=9AexecuteToolCall=20/=20e?= =?UTF-8?q?xecutePendingTool=20=E4=B8=A4=E5=A4=84=E5=86=99=E5=B7=A5?= =?UTF-8?q?=E5=85=B7=E6=89=A7=E8=A1=8C=E5=90=8E=EF=BC=8C=E9=80=9A=E8=BF=87?= =?UTF-8?q?=20registry.IsWriteTool=20=E5=88=A4=E6=96=AD=E5=B9=B6=E7=BD=AE?= =?UTF-8?q?=20HasScheduleWriteOps=3Dtrue=20-=20newAgent/graph/common=5Fgra?= =?UTF-8?q?ph.go=EF=BC=9AbranchAfterExecute=20=E5=88=86=E6=94=AF=E6=9D=A1?= =?UTF-8?q?=E4=BB=B6=E6=96=B0=E5=A2=9E=20HasScheduleWriteOps=20=E5=88=A4?= =?UTF-8?q?=E6=96=AD=EF=BC=8C=E9=9D=9E=E6=97=A5=E7=A8=8B=E6=93=8D=E4=BD=9C?= =?UTF-8?q?=EF=BC=88query=5Ftasks=20/=20quick=5Fnote=5Fcreate=20/=20web=5F?= =?UTF-8?q?search=20=E7=AD=89=EF=BC=89=E7=9B=B4=E6=8E=A5=20deliver=20?= =?UTF-8?q?=E8=B7=B3=E8=BF=87=20order=5Fguard=EF=BC=9BbranchAfterRoughBuil?= =?UTF-8?q?d=20=E4=B8=8D=E5=8F=98=EF=BC=8C=E7=B2=97=E6=8E=92=E5=A4=A9?= =?UTF-8?q?=E7=84=B6=E6=98=AF=E5=86=99=E6=93=8D=E4=BD=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 前端: 1. 助手面板新增 SSE 流式请求停止按钮 - AssistantPanel.vue:新增 streamAbortController ref 和 stopStreaming 方法;fetchChatStream / streamAssistantReply 透传 AbortSignal;sendMessage 创建 AbortController,catch 区分用户主动中断与异常;流式期间显示红色停止按钮替代发送按钮 2. 象限卡片任务列表取消硬截断,改为滚动查看 - TaskQuadrantCard.vue:visibleTasks 不再 slice(0,4),全部展示;quadrant-list 新增 max-height + overflow-y + 自定义滚动条样式 仓库:无 --- backend/cmd/start.go | 36 ++ backend/newAgent/graph/common_graph.go | 2 +- backend/newAgent/model/common_state.go | 4 + backend/newAgent/node/execute.go | 10 + backend/newAgent/prompt/execute.go | 16 +- backend/newAgent/tools/registry.go | 16 + backend/newAgent/tools/taskquery.go | 320 ++++++++++++++++++ backend/service/agentsvc/agent_task_query.go | 4 +- .../components/dashboard/AssistantPanel.vue | 90 ++++- .../components/dashboard/TaskQuadrantCard.vue | 26 +- 10 files changed, 498 insertions(+), 26 deletions(-) create mode 100644 backend/newAgent/tools/taskquery.go diff --git a/backend/cmd/start.go b/backend/cmd/start.go index f8cd594..9b148f8 100644 --- a/backend/cmd/start.go +++ b/backend/cmd/start.go @@ -6,6 +6,7 @@ import ( "log" "time" + agentnode "github.com/LoveLosita/smartflow/backend/agent/node" "github.com/LoveLosita/smartflow/backend/api" "github.com/LoveLosita/smartflow/backend/dao" kafkabus "github.com/LoveLosita/smartflow/backend/infra/kafka" @@ -197,6 +198,41 @@ func Start() { return created.ID, nil }, }, + TaskQuery: newagenttools.TaskQueryDeps{ + // 调用目的:桥接新工具参数到旧 service 层查询能力,复用已有的过滤/排序/紧急度提升逻辑。 + QueryTasks: func(ctx context.Context, userID int, params newagenttools.TaskQueryParams) ([]newagenttools.TaskQueryResult, error) { + req := agentnode.TaskQueryRequest{ + UserID: userID, + Quadrant: params.Quadrant, + SortBy: params.SortBy, + Order: params.Order, + Limit: params.Limit, + IncludeCompleted: params.IncludeCompleted, + Keyword: params.Keyword, + DeadlineBefore: params.DeadlineBefore, + DeadlineAfter: params.DeadlineAfter, + } + records, err := agentService.QueryTasksForTool(ctx, req) + if err != nil { + return nil, err + } + results := make([]newagenttools.TaskQueryResult, 0, len(records)) + for _, r := range records { + deadlineStr := "" + if r.DeadlineAt != nil { + deadlineStr = r.DeadlineAt.In(time.Local).Format("2006-01-02 15:04") + } + results = append(results, newagenttools.TaskQueryResult{ + ID: r.ID, + Title: r.Title, + PriorityGroup: r.PriorityGroup, + IsCompleted: r.IsCompleted, + DeadlineAt: deadlineStr, + }) + } + return results, nil + }, + }, })) agentService.SetScheduleProvider(newagentconv.NewScheduleProvider(scheduleRepo, taskClassRepo)) agentService.SetSchedulePersistor(newagentconv.NewSchedulePersistorAdapter(manager)) diff --git a/backend/newAgent/graph/common_graph.go b/backend/newAgent/graph/common_graph.go index 8be8368..be8c064 100644 --- a/backend/newAgent/graph/common_graph.go +++ b/backend/newAgent/graph/common_graph.go @@ -298,7 +298,7 @@ func branchAfterExecute(_ context.Context, st *newagentmodel.AgentGraphState) (s // 3. 若此处直接按 RoundUsed>=MaxRounds 跳 Deliver,会绕过 Execute 内的 Exhaust 写入, // 导致 deliver 收口和后续预览落盘语义不一致。 if flowState.Phase == newagentmodel.PhaseDone { - if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder { + if flowState.TerminalStatus() == newagentmodel.FlowTerminalStatusCompleted && !flowState.AllowReorder && flowState.HasScheduleWriteOps { return NodeOrderGuard, nil } return NodeDeliver, nil diff --git a/backend/newAgent/model/common_state.go b/backend/newAgent/model/common_state.go index 26e6fa5..93ae7e3 100644 --- a/backend/newAgent/model/common_state.go +++ b/backend/newAgent/model/common_state.go @@ -109,6 +109,9 @@ type CommonState struct { // SuggestedOrderBaseline 保存"本轮 execute 启动前"的 suggested 任务相对顺序基线。 // OrderGuard 节点会基于该基线判断微调是否破坏顺序约束。 SuggestedOrderBaseline []int `json:"suggested_order_baseline,omitempty"` + // HasScheduleWriteOps 标记本轮 execute 循环是否执行过日程写工具。 + // 调用目的:graph 分支函数据此判断是否需要走 order_guard,非日程操作跳过守卫。 + HasScheduleWriteOps bool `json:"has_schedule_write_ops,omitempty"` // ExecuteThinking 由 Chat 路由决策传入,表示 Execute 节点是否应开启深度思考。 // 预埋字段,当前阶段 Execute 节点可自行决定是否读取。 @@ -218,6 +221,7 @@ func (s *CommonState) ResetForNextRun() { // 5. 重置顺序约束临时态与终止结果,避免上一轮 completed/aborted/exhausted 语义串到下一轮。 s.AllowReorder = false + s.HasScheduleWriteOps = false s.SuggestedOrderBaseline = nil s.ClearTerminalOutcome() } diff --git a/backend/newAgent/node/execute.go b/backend/newAgent/node/execute.go index adcdc9e..ae10451 100644 --- a/backend/newAgent/node/execute.go +++ b/backend/newAgent/node/execute.go @@ -1452,6 +1452,11 @@ func executeToolCall( // 3. 以标准 assistant+tool 消息对写回历史,避免消息链断裂。 appendToolCallResultHistory(conversationContext, toolName, toolCall.Arguments, result) + // 3.1 标记本轮执行过日程写工具,graph 分支据此决定是否走 order_guard。 + if registry.IsWriteTool(toolName) { + flowState.HasScheduleWriteOps = true + } + // 4. 写工具实时预览:每次写工具执行后都尝试刷新 Redis 预览,确保前端可见“最新操作结果”。 // // 步骤化说明: @@ -1563,6 +1568,11 @@ func executePendingTool( // 5. 将工具调用和结果写回历史,维持标准 tool_call 配对格式。 appendToolCallResultHistory(conversationContext, pending.ToolName, args, result) + // 5.1 标记本轮执行过日程写工具,graph 分支据此决定是否走 order_guard。 + if registry.IsWriteTool(pending.ToolName) { + flowState.HasScheduleWriteOps = true + } + // 5. 写工具实时预览:confirm accept 后真实执行写工具时,立即刷新一次预览缓存。 tryWritePreviewAfterWriteTool(ctx, flowState, scheduleState, registry, pending.ToolName, writePreview) diff --git a/backend/newAgent/prompt/execute.go b/backend/newAgent/prompt/execute.go index 76c70b8..ec8ea3d 100644 --- a/backend/newAgent/prompt/execute.go +++ b/backend/newAgent/prompt/execute.go @@ -40,10 +40,11 @@ const executeSystemPromptWithPlan = ` 2. 读操作:action=continue + tool_call。 3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。 4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成;调用成功后可继续(done/next_plan/continue)处理其他任务,但不要为 quick_note_create 本身补充说明。 -5. 缺关键上下文且无法通过工具补齐:action=ask_user。 -6. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。 -7. 仅当整体任务完成时输出 action=done,并在 goal_check 总结完成证据。 -8. 流程应正式终止时输出 action=abort。` +5. query_tasks(查看/筛选任务列表):读操作,action=continue + tool_call。用于回答"我有什么任务""最近有什么急事"等问题,支持按象限、关键词、截止时间范围筛选和排序。 +6. 缺关键上下文且无法通过工具补齐:action=ask_user。 +7. 仅当当前步骤完成时输出 action=next_plan,并在 goal_check 对照 done_when 给出证据。 +8. 仅当整体任务完成时输出 action=done,并在 goal_check 总结完成证据。 +9. 流程应正式终止时输出 action=abort。` const executeSystemPromptReAct = ` 你是 SmartMate 的执行器,当前处于自由执行模式(无预定义 plan 步骤)。 @@ -82,9 +83,10 @@ const executeSystemPromptReAct = ` 2. 读操作:action=continue + tool_call。 3. 写操作(日程变更,如 place/move/swap/batch_move/unplace/spread_even/min_context_switch):action=confirm + tool_call。 4. quick_note_create(记录任务/提醒):若信息足够,action=continue + tool_call,并显式填写 priority_group;若信息不足且无法可靠推断,action=ask_user 先追问。quick_note_create 调用时和调用后 speak 必须留空,收口由 deliver 阶段统一完成;调用成功后可继续(done/next_plan/continue)处理其他任务,但不要为 quick_note_create 本身补充说明。 -5. 缺关键上下文且无法通过工具补齐:action=ask_user。 -6. 任务完成:action=done,并在 goal_check 总结完成证据。 -7. 流程应正式终止:action=abort。` +5. query_tasks(查看/筛选任务列表):读操作,action=continue + tool_call。用于回答"我有什么任务""最近有什么急事"等问题,支持按象限、关键词、截止时间范围筛选和排序。 +6. 缺关键上下文且无法通过工具补齐:action=ask_user。 +7. 任务完成:action=done,并在 goal_check 总结完成证据。 +8. 流程应正式终止:action=abort。` // BuildExecuteSystemPrompt 返回执行阶段系统提示词(有 plan 模式)。 func BuildExecuteSystemPrompt() string { diff --git a/backend/newAgent/tools/registry.go b/backend/newAgent/tools/registry.go index 53fb0cd..315e4e8 100644 --- a/backend/newAgent/tools/registry.go +++ b/backend/newAgent/tools/registry.go @@ -34,6 +34,9 @@ type DefaultRegistryDeps struct { // QuickNote 随口记工具依赖。CreateTask 为 nil 时 quick_note_create 返回错误提示,不阻断主流程。 QuickNote QuickNoteDeps + + // TaskQuery 任务查询工具依赖。QueryTasks 为 nil 时 query_tasks 不注册,不影响其他工具。 + TaskQuery TaskQueryDeps } // ToolRegistry 管理工具注册、查找与执行。 @@ -127,6 +130,7 @@ var writeTools = map[string]bool{ var scheduleFreeTools = map[string]bool{ "quick_note_create": true, + "query_tasks": true, "web_search": true, "web_fetch": true, } @@ -340,6 +344,18 @@ func NewDefaultRegistryWithDeps(deps DefaultRegistryDeps) *ToolRegistry { ) } + // --- 任务查询读工具 --- + // 调用目的:将"帮我看看有什么任务""最近有什么急事"等查询请求直接查库返回结构化结果,无需 ScheduleState。 + // 不加入 writeTools:查询是只读操作,不需要 confirm 节点二次确认。 + if deps.TaskQuery.QueryTasks != nil { + taskQueryHandler := NewTaskQueryToolHandler(deps.TaskQuery) + r.Register("query_tasks", + "按象限、关键词、截止时间筛选并排序任务列表,返回结构化结果。所有参数均为可选。", + `{"name":"query_tasks","parameters":{"quadrant":{"type":"int","description":"可选象限筛选(1~4)"},"keyword":{"type":"string","description":"可选标题关键词,模糊匹配"},"deadline_before":{"type":"string","description":"可选截止时间上界,支持 yyyy-MM-dd HH:mm 或 yyyy-MM-dd"},"deadline_after":{"type":"string","description":"可选截止时间下界,支持 yyyy-MM-dd HH:mm 或 yyyy-MM-dd"},"sort_by":{"type":"string","description":"排序字段(deadline|priority|id),默认deadline"},"order":{"type":"string","description":"排序方向(asc|desc),默认asc"},"limit":{"type":"int","description":"返回条数,默认5,上限20"},"include_completed":{"type":"bool","description":"是否包含已完成任务,默认false"}}}`, + taskQueryHandler, + ) + } + // --- Web 搜索读工具 --- // 1. provider 为 nil 时 handler 返回"暂未启用"的 observation,不会阻断主流程; // 2. 两个工具均为读操作,走 action=continue + tool_call 模式。 diff --git a/backend/newAgent/tools/taskquery.go b/backend/newAgent/tools/taskquery.go new file mode 100644 index 0000000..4413b67 --- /dev/null +++ b/backend/newAgent/tools/taskquery.go @@ -0,0 +1,320 @@ +package newagenttools + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "time" + + "github.com/LoveLosita/smartflow/backend/newAgent/tools/schedule" +) + +// ==================== 常量 ==================== + +const ( + // defaultTaskQueryLimit 是任务查询默认返回条数。 + defaultTaskQueryLimit = 5 + // maxTaskQueryLimit 是任务查询允许的最大返回条数,用于限制 LLM 输出范围。 + maxTaskQueryLimit = 20 +) + +// ==================== 优先级中文映射 ==================== + +// taskQueryPriorityLabelCN 将象限编号转为中文标签。 +// +// 职责边界: +// 1. 只负责 1~4 的合法映射,超出范围返回"未知"。 +// 2. 不依赖旧链路 agentmodel.PriorityLabelCN,保持新工具自包含。 +func taskQueryPriorityLabelCN(priority int) string { + switch priority { + case 1: + return "重要且紧急" + case 2: + return "重要不紧急" + case 3: + return "简单不重要" + case 4: + return "复杂不重要" + default: + return "未知" + } +} + +// ==================== 类型定义 ==================== + +// TaskQueryDeps 描述任务查询工具所需的外部依赖。 +// +// 职责边界: +// 1. QueryTasks 负责真正查库,工具层不直接依赖 DAO; +// 2. UserID 由 execute 节点通过 args["_user_id"] 注入,工具层不自行解析会话身份。 +type TaskQueryDeps struct { + // QueryTasks 将解析后的查询参数传入业务层,返回匹配的任务列表。 + // 调用目的:解耦工具层与 DAO 层,方便测试和替换。 + QueryTasks func(ctx context.Context, userID int, params TaskQueryParams) ([]TaskQueryResult, error) +} + +// TaskQueryParams 描述任务查询工具传给业务层的内部查询参数。 +// +// 输入输出语义: +// 1. 所有筛选条件均为可选,Quadrant 为 nil 表示不限象限。 +// 2. 时间边界为 nil 表示不限时间范围。 +type TaskQueryParams struct { + Quadrant *int + SortBy string // deadline | priority | id + Order string // asc | desc + Limit int + IncludeCompleted bool + Keyword string + DeadlineBefore *time.Time + DeadlineAfter *time.Time +} + +// TaskQueryResult 描述任务查询工具返回给 LLM 的轻量任务视图。 +// +// 职责边界: +// 1. 只承载展示所需字段,避免暴露底层数据库结构。 +// 2. JSON 序列化后直接作为工具 observation 返回给 LLM。 +type TaskQueryResult struct { + ID int `json:"id"` + Title string `json:"title"` + PriorityGroup int `json:"priority_group"` + PriorityLabel string `json:"priority_label"` + IsCompleted bool `json:"is_completed"` + DeadlineAt string `json:"deadline_at,omitempty"` +} + +// ==================== 时间解析 ==================== + +// taskQueryTimeLayouts 支持的时间格式列表,按优先级尝试解析。 +var taskQueryTimeLayouts = []string{ + time.RFC3339, + "2006-01-02 15:04:05", + "2006-01-02 15:04", + "2006-01-02", +} + +// parseTaskQueryBoundaryTime 解析截止时间上下界。 +// +// 职责边界: +// 1. isUpper=true 时,纯日期补到当天 23:59:59。 +// 2. isUpper=false 时,纯日期补到当天 00:00:00。 +// 3. 不支持的格式直接返回错误,由调用方决定是否回退。 +func parseTaskQueryBoundaryTime(raw string, isUpper bool) (*time.Time, error) { + text := strings.TrimSpace(raw) + if text == "" { + return nil, nil + } + + loc := time.Local + for _, layout := range taskQueryTimeLayouts { + var ( + parsed time.Time + err error + ) + if layout == time.RFC3339 { + parsed, err = time.Parse(layout, text) + if err == nil { + parsed = parsed.In(loc) + } + } else { + parsed, err = time.ParseInLocation(layout, text, loc) + } + if err != nil { + continue + } + + // 1. 纯日期格式需要根据上下界补齐时分秒,保证时间区间语义正确。 + // 2. 若用户输入"2026-04-20"作为上界,意图是"截止到那天结束", + // 所以补 23:59:59;作为下界则补 00:00:00。 + if layout == "2006-01-02" { + if isUpper { + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 23, 59, 59, 0, loc) + } else { + parsed = time.Date(parsed.Year(), parsed.Month(), parsed.Day(), 0, 0, 0, 0, loc) + } + } + return &parsed, nil + } + return nil, fmt.Errorf("时间格式不支持: %s", text) +} + +// formatTaskQueryTime 将内部时间格式化为给模型展示的分钟级文本。 +func formatTaskQueryTime(value *time.Time) string { + if value == nil { + return "" + } + return value.In(time.Local).Format("2006-01-02 15:04") +} + +// ==================== 工具 Handler ==================== + +// NewTaskQueryToolHandler 创建 query_tasks 工具的 handler 闭包。 +// +// 职责边界: +// 1. 负责参数校验、时间解析、调 deps 查库、组装返回; +// 2. 不负责 LLM 交互和会话管理。 +// 3. state 参数忽略——任务查询不需要 ScheduleState,已注册到 scheduleFreeTools。 +func NewTaskQueryToolHandler(deps TaskQueryDeps) ToolHandler { + return func(state *schedule.ScheduleState, args map[string]any) string { + _ = state + + // 1. 提取 _user_id(由 execute 节点在调用前注入)。 + userID := 0 + if uid, ok := args["_user_id"].(int); ok { + userID = uid + } + if userID <= 0 { + return "工具调用失败:无法识别用户身份。" + } + + // 2. 提取并校验查询参数。 + params, err := extractTaskQueryParams(args) + if err != nil { + return fmt.Sprintf("工具调用失败:%s", err) + } + + // 3. 调用依赖查库。 + results, err := deps.QueryTasks(context.Background(), userID, params) + if err != nil { + return fmt.Sprintf("工具调用失败:查询任务时出错(%s)。", err) + } + + // 4. 为每条结果填充优先级中文标签。 + for i := range results { + results[i].PriorityLabel = taskQueryPriorityLabelCN(results[i].PriorityGroup) + } + + // 5. 返回结构化 JSON。 + if len(results) == 0 { + return `{"total":0,"items":[],"message":"当前没有匹配的任务。"}` + } + + output := struct { + Total int `json:"total"` + Items []TaskQueryResult `json:"items"` + Message string `json:"message"` + }{ + Total: len(results), + Items: results, + Message: fmt.Sprintf("找到 %d 条匹配任务。", len(results)), + } + + jsonBytes, marshalErr := json.Marshal(output) + if marshalErr != nil { + // JSON 序列化失败时降级为纯文本,确保 LLM 仍能拿到关键信息。 + return fmt.Sprintf("找到 %d 条匹配任务。", len(results)) + } + return string(jsonBytes) + } +} + +// extractTaskQueryParams 从 args 提取并校验任务查询参数。 +// +// 步骤说明: +// 1. 先准备默认值,保证空参数也能执行一次合理查询。 +// 2. 再校验象限、排序、条数和时间区间,阻止非法参数下沉到业务层。 +// 3. 若上下界冲突,则直接返回错误。 +func extractTaskQueryParams(args map[string]any) (TaskQueryParams, error) { + params := TaskQueryParams{ + SortBy: "deadline", + Order: "asc", + Limit: defaultTaskQueryLimit, + IncludeCompleted: false, + } + + // 2.1 象限:1~4,超出范围拒绝。 + if v, ok := args["quadrant"]; ok { + switch val := v.(type) { + case float64: + q := int(val) + if q < 1 || q > 4 { + return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", q) + } + params.Quadrant = &q + case int: + if val < 1 || val > 4 { + return TaskQueryParams{}, fmt.Errorf("quadrant=%d 非法,必须在 1~4", val) + } + params.Quadrant = &val + } + } + + // 2.2 排序字段:仅支持 deadline/priority/id。 + if v, ok := args["sort_by"].(string); ok { + sortBy := strings.ToLower(strings.TrimSpace(v)) + if sortBy != "" { + switch sortBy { + case "deadline", "priority", "id": + params.SortBy = sortBy + default: + return TaskQueryParams{}, fmt.Errorf("sort_by=%s 非法,仅支持 deadline|priority|id", sortBy) + } + } + } + + // 2.3 排序方向:仅支持 asc/desc。 + if v, ok := args["order"].(string); ok { + order := strings.ToLower(strings.TrimSpace(v)) + if order != "" { + switch order { + case "asc", "desc": + params.Order = order + default: + return TaskQueryParams{}, fmt.Errorf("order=%s 非法,仅支持 asc|desc", order) + } + } + } + + // 2.4 条数:默认 5,上限 20。 + if v, ok := args["limit"]; ok { + switch val := v.(type) { + case float64: + params.Limit = int(val) + case int: + params.Limit = val + } + } + if params.Limit <= 0 { + params.Limit = defaultTaskQueryLimit + } + if params.Limit > maxTaskQueryLimit { + params.Limit = maxTaskQueryLimit + } + + // 2.5 是否包含已完成任务。 + if v, ok := args["include_completed"]; ok { + switch val := v.(type) { + case bool: + params.IncludeCompleted = val + } + } + + // 2.6 关键词。 + if v, ok := args["keyword"].(string); ok { + params.Keyword = strings.TrimSpace(v) + } + + // 2.7 时间边界解析,解析失败直接报错,避免查出无意义的结果。 + beforeRaw, _ := args["deadline_before"].(string) + before, err := parseTaskQueryBoundaryTime(beforeRaw, true) + if err != nil { + return TaskQueryParams{}, fmt.Errorf("deadline_before 格式错误: %s", err) + } + params.DeadlineBefore = before + + afterRaw, _ := args["deadline_after"].(string) + after, err := parseTaskQueryBoundaryTime(afterRaw, false) + if err != nil { + return TaskQueryParams{}, fmt.Errorf("deadline_after 格式错误: %s", err) + } + params.DeadlineAfter = after + + // 2.8 时间区间合法性校验:下界不能晚于上界。 + if params.DeadlineBefore != nil && params.DeadlineAfter != nil && + params.DeadlineAfter.After(*params.DeadlineBefore) { + return TaskQueryParams{}, fmt.Errorf("deadline_after 不能晚于 deadline_before") + } + + return params, nil +} diff --git a/backend/service/agentsvc/agent_task_query.go b/backend/service/agentsvc/agent_task_query.go index 759845c..d979b95 100644 --- a/backend/service/agentsvc/agent_task_query.go +++ b/backend/service/agentsvc/agent_task_query.go @@ -38,13 +38,13 @@ func (s *AgentService) runTaskQueryFlow( Deps: agentnode.TaskQueryToolDeps{ QueryTasks: func(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { req.UserID = userID - return s.queryTasksForAgent(ctx, req) + return s.QueryTasksForTool(ctx, req) }, }, }) } -func (s *AgentService) queryTasksForAgent(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { +func (s *AgentService) QueryTasksForTool(ctx context.Context, req agentnode.TaskQueryRequest) ([]agentnode.TaskQueryTaskRecord, error) { _ = ctx if req.UserID <= 0 { return nil, errors.New("invalid user_id in task query") diff --git a/frontend/src/components/dashboard/AssistantPanel.vue b/frontend/src/components/dashboard/AssistantPanel.vue index 6c21152..b312bed 100644 --- a/frontend/src/components/dashboard/AssistantPanel.vue +++ b/frontend/src/components/dashboard/AssistantPanel.vue @@ -99,6 +99,8 @@ const selectedThinkingMode = ref('auto') const messageInput = ref('') const historyPanelWidth = ref(props.initialHistoryWidth) const activeStreamingMessageId = ref('') +// 流式请求的 AbortController:发送时创建,流结束或用户点击停止时 abort。 +const streamAbortController = ref(null) const editingUserMessageId = ref('') const editingUserMessageDraft = ref('') const pendingPlanningTaskClassIds = ref([]) @@ -1124,7 +1126,7 @@ function handlePlanningSelectionApplied(taskClassIds: number[]) { // 1. 只负责把请求发出去并返回原始 Response,不在这里解析 SSE 数据。 // 2. 401 时优先尝试用 refresh token 换新 access token,并只重试一次,避免死循环。 // 3. 若最终仍未通过鉴权,则清空本地登录态,让页面统一回到重新登录的安全状态。 -async function fetchChatStream(body: ChatStreamRequest, attempt = 0): Promise { +async function fetchChatStream(body: ChatStreamRequest, signal?: AbortSignal, attempt = 0): Promise { const response = await fetch('/api/v1/agent/chat', { method: 'POST', headers: { @@ -1132,6 +1134,7 @@ async function fetchChatStream(body: ChatStreamRequest, attempt = 0): Promise { const response = await fetchChatStream({ conversation_id: isDraftConversationId(draftConversationId) ? undefined : draftConversationId, @@ -1269,7 +1273,7 @@ async function streamAssistantReply( model: 'worker', thinking: selectedThinkingMode.value, extra: requestExtra, - }) + }, signal) const responseConversationId = response.headers.get('X-Conversation-ID')?.trim() const actualConversationId = responseConversationId || draftConversationId @@ -1319,7 +1323,14 @@ async function streamAssistantReply( return actualConversationId } -// sendMessage 负责执行“本地先上屏,再异步接流”的发送链路。 +// stopStreaming 负责中断正在进行的 SSE 流式请求。 +// 职责边界:只调用 AbortController.abort(),不修改 chatLoading 等状态—— +// 这些状态由 sendMessage 的 finally 块统一清理,避免多处重置导致状态不一致。 +function stopStreaming() { + streamAbortController.value?.abort() +} + +// sendMessage 负责执行”本地先上屏,再异步接流”的发送链路。 // 职责边界: // 1. 先创建用户消息和 assistant 占位消息,让发送动作立即反馈到界面,等待建连过程无感化。 // 2. 若当前是新会话,则先使用 draft 会话承接本地状态,等响应头返回真实 conversation_id 后再整体迁移。 @@ -1333,12 +1344,10 @@ async function sendMessage(preset?: string) { chatLoading.value = true const planningTaskClassIdsForRequest = [...pendingPlanningTaskClassIds.value] - const shouldStartFreshPlanningConversation = planningTaskClassIdsForRequest.length > 0 - const draftConversationId = shouldStartFreshPlanningConversation - ? createDraftConversationId() - : (selectedConversationId.value || createDraftConversationId()) + // 智能编排不再强制新开对话:直接沿用当前会话,在原地发送编排请求。 + const draftConversationId = selectedConversationId.value || createDraftConversationId() - if (!selectedConversationId.value || shouldStartFreshPlanningConversation) { + if (!selectedConversationId.value) { selectedConversationId.value = draftConversationId } ensureConversationBucket(draftConversationId) @@ -1368,6 +1377,10 @@ async function sendMessage(preset?: string) { prependConversationPreview(draftConversationId, text, now) scheduleScrollMessagesToBottom(false, true) + // 1. 创建 AbortController:用户点击停止按钮时可通过 controller.abort() 中断 fetch 请求。 + const controller = new AbortController() + streamAbortController.value = controller + try { const actualConversationId = await streamAssistantReply( draftConversationId, @@ -1376,6 +1389,7 @@ async function sendMessage(preset?: string) { now, true, buildChatRequestExtra(planningTaskClassIdsForRequest), + controller.signal, ) if (planningTaskClassIdsForRequest.length > 0) { pendingPlanningTaskClassIds.value = [] @@ -1387,12 +1401,20 @@ async function sendMessage(preset?: string) { loadConversationContextStats(actualConversationId, true), ]) } catch (error) { - if (!assistantMessage.content.trim()) { - assistantMessage.content = '本次回复已中断,请稍后重试。' + // 用户主动中断:不弹出错误提示,只给占位消息补一段中断文案。 + if (controller.signal.aborted) { + if (!assistantMessage.content.trim()) { + assistantMessage.content = '本次回复已手动停止。' + } + } else { + if (!assistantMessage.content.trim()) { + assistantMessage.content = '本次回复已中断,请稍后重试。' + } + ElMessage.error(error instanceof Error ? error.message : '发送消息失败,请稍后重试') } reasoningCollapsedMap[assistantMessage.id] = false - ElMessage.error(error instanceof Error ? error.message : '发送消息失败,请稍后重试') } finally { + streamAbortController.value = null activeStreamingMessageId.value = '' chatLoading.value = false } @@ -1779,12 +1801,29 @@ onBeforeUnmount(() => { > + +