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(() => { > + +