From ae70e4902e76bc6486245bddebee4f33b1aaa26d Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Tue, 14 Jul 2026 18:34:56 +0800 Subject: [PATCH] =?UTF-8?q?refactor(stop-button):=20=E6=8B=86=E5=88=86?= =?UTF-8?q?=E5=81=9C=E6=AD=A2=E6=8C=89=E9=92=AE=E5=92=8C=E5=90=8E=E5=8F=B0?= =?UTF-8?q?=E4=BB=BB=E5=8A=A1=E6=8E=A7=E5=88=B6?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 停止按钮现在只停主智能体,与后台任务无关。 - 后端 cancel_task 简化:移除两段式判断、force_stop参数、is_ma_mode分支 - 删除 _force_stop_multi_agent 方法 - 新增 /api/sub_agents/stop_all 接口(mode=terminate/soft_stop) - 前端 stopTask 移除 forceStop 概念,只取消主智能体 - 取消期间不再二次点击终结子智能体 子智能体停止按钮: - InputComposer state bar 加'x个子智能体运行中'按钮 - 独立浮层显示,不依赖 git 栏(参考 askuser 方案) - 点击传统模式弹窗终结;多智能体弹窗软停止 - App.vue 新增 handleStopAllSubAgents 调 ui.confirmDialog 后台指令停止按钮: - LeftPanel 后台指令标签右边加停止按钮 - 仅当 panelMode=backgroundCommands 且存在运行中后台指令时显示 - 调 backgroundCommandStore.stopAllCommands 无二次确认 showStopIcon 只看主对话 streaming 状态: - 后台任务在跑但主对话空闲时显示发送按钮 - 多智能体 / 传统模式统一处理 子智能体 store 加 stopAllAgents(mode) action 调新 API useLegacySocket task_stopped handler 简化: - 移除 multi_agent_reloaded 重载 - 移除'再次按下停止按钮以结束后台任务'提示 - 保留 has_running_multi_agent/sub_agents 用于 taskInProgress 判定 清理调试日志:[TaskCancel][stop_debug], [STOP_DEBUG] 全部去掉 保留 ma_debug 框架用于多智能体调试 --- modules/sub_agent/manager.py | 22 +- modules/sub_agent/task.py | 22 +- server/conversation.py | 49 ++++ server/tasks/api.py | 4 +- server/tasks/models.py | 249 ++---------------- static/src/App.vue | 2 + static/src/app/computed.ts | 13 + static/src/app/methods/message/send.ts | 100 ++----- static/src/app/methods/ui/mode.ts | 50 ++++ static/src/components/input/InputComposer.vue | 40 ++- static/src/components/panels/LeftPanel.vue | 24 ++ static/src/composables/useLegacySocket.ts | 59 +---- static/src/stores/subAgent.ts | 18 ++ static/src/stores/task.ts | 8 +- .../styles/components/panels/_left-panel.scss | 25 ++ 15 files changed, 293 insertions(+), 392 deletions(-) diff --git a/modules/sub_agent/manager.py b/modules/sub_agent/manager.py index f7333e3..a24d7d0 100644 --- a/modules/sub_agent/manager.py +++ b/modules/sub_agent/manager.py @@ -393,7 +393,7 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi 让子智能体在当前工具完成后进入 idle 状态,保留上下文。 返回实际发出软停止信号的子智能体数量。 """ - debug_log(f"[TaskCancel][stop_debug] soft_stop_all_agents 进入,conv={conversation_id}") + ma_debug("soft_stop_all_agents_enter", conversation_id=conversation_id) count = 0 matched = 0 skipped_terminal = 0 @@ -403,27 +403,35 @@ class SubAgentManager(SubAgentStateMixin, SubAgentStatsMixin, SubAgentCreationMi continue matched += 1 status = task_info.get("status") - debug_log(f"[TaskCancel][stop_debug] soft_stop 遍历: task_id={task_id}, status={status}") + ma_debug( + "soft_stop_iter", + task_id=task_id, + status=status, + ) if status in TERMINAL_STATUSES.union({"terminated", "idle"}): skipped_terminal += 1 - debug_log(f"[TaskCancel][stop_debug] soft_stop 跳过 (terminal/idle): task_id={task_id}, status={status}") continue agent_id = task_info.get("agent_id") if agent_id is None: continue # 從 _sub_agent_instances 查找 SubAgentTask 实例 sub_agent_task = self._sub_agent_instances.get(agent_id) - debug_log(f"[TaskCancel][stop_debug] soft_stop _sub_agent_instances 查找: agent_id={agent_id}, found={bool(sub_agent_task)}") if sub_agent_task and hasattr(sub_agent_task, "request_soft_stop"): try: sub_agent_task.request_soft_stop() count += 1 - debug_log(f"[TaskCancel][stop_debug] soft_stop 已请求: task_id={task_id}, agent_id={agent_id}") except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] soft_stop 请求失败: task_id={task_id}, error={exc}") + ma_debug("soft_stop_failed", task_id=task_id, error=str(exc)) else: skipped_no_instance += 1 - debug_log(f"[TaskCancel][stop_debug] soft_stop_all_agents 结束: matched={matched}, count={count}, skipped_terminal={skipped_terminal}, skipped_no_instance={skipped_no_instance}") + ma_debug( + "soft_stop_all_agents_done", + conversation_id=conversation_id, + matched=matched, + count=count, + skipped_terminal=skipped_terminal, + skipped_no_instance=skipped_no_instance, + ) return count def terminate_sub_agent( diff --git a/modules/sub_agent/task.py b/modules/sub_agent/task.py index 58f227e..0b4fcdc 100644 --- a/modules/sub_agent/task.py +++ b/modules/sub_agent/task.py @@ -1069,32 +1069,32 @@ class SubAgentTask: - request_soft_stop(): 设标志 + cancel asyncio.Task → CancelledError → 捕获后重载对话文件(丢弃半成品)→ mark idle → 保留实例 """ - from server.utils_common import debug_log as _dbg - _dbg(f"[TaskCancel][stop_debug] request_soft_stop 进入: task_id={self.task_id}, agent_id={self.agent_id}, _soft_stop 前={self._soft_stop}, _task={self._task}, _task.done()={self._task.done() if self._task else 'n/a'}") self._soft_stop = True + ma_debug( + "sub_agent_request_soft_stop", + task_id=self.task_id, + agent_id=self.agent_id, + has_task=bool(self._task), + task_done=self._task.done() if self._task else None, + ) # 取消子智能体正在阻塞等待的 ask_master/ask_other 未来,解除阻塞 if self.multi_agent_state and self.agent_id is not None: try: self.multi_agent_state.cancel_pending_question_for_agent(self.agent_id) - _dbg(f"[TaskCancel][stop_debug] request_soft_stop cancel_pending_question_for_agent 已调 agent_id={self.agent_id}") - except Exception as exc: - _dbg(f"[TaskCancel][stop_debug] request_soft_stop cancel_pending_question_for_agent 失败: {exc}") + except Exception: + pass # 直接 cancel asyncio.Task 以中断正在执行的工具调用/API 调用 # CancelledError 会在 run() 中被捕获,检测到 _soft_stop 后走恢复路径 if self._task and not self._task.done(): try: loop = self._task.get_loop() loop.call_soon_threadsafe(self._task.cancel) - _dbg(f"[TaskCancel][stop_debug] request_soft_stop call_soon_threadsafe(cancel) 已投递 task_id={self.task_id}") - except Exception as exc: - _dbg(f"[TaskCancel][stop_debug] request_soft_stop call_soon_threadsafe 失败: {exc}") + except Exception: self._task.cancel() - else: - _dbg(f"[TaskCancel][stop_debug] request_soft_stop 无可用 _task,无法 cancel (done or None): task_id={self.task_id}") # 额外保险:如果 _task 为 None 但子智能体状态是 running,直接设 idle 状态 if self._task is None and self.multi_agent_state and self.agent_id is not None: - _dbg(f"[TaskCancel][stop_debug] request_soft_stop _task 为 None,直接 mark idle: task_id={self.task_id}") + ma_debug("sub_agent_soft_stop_no_task_mark_idle", task_id=self.task_id, agent_id=self.agent_id) self._soft_stop = False self._idle = True self.multi_agent_state.mark_status(self.agent_id, "idle") diff --git a/server/conversation.py b/server/conversation.py index 2f657df..0b3e7c1 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -1808,6 +1808,55 @@ def get_sub_agent_activity(task_id: str, terminal: WebTerminal, workspace: UserW return jsonify({"success": False, "error": str(exc)}), 500 +@conversation_bp.route('/api/sub_agents/stop_all', methods=['POST']) +@api_login_required +@with_terminal +def stop_all_sub_agents(terminal: WebTerminal, workspace: UserWorkspace, username: str): + """批量停止当前会话的所有非终态子智能体。 + + body: {"mode": "terminate" | "soft_stop"} + - mode=terminate: 传统模式终结子智能体(重、不保留实例) + - mode=soft_stop: 多智能体模式软停止(轻、保留实例可被后续唤醒) + """ + data = request.get_json(silent=True) or {} + mode = str(data.get('mode') or 'terminate').strip().lower() + if mode not in {'terminate', 'soft_stop'}: + return jsonify({"success": False, "error": "mode 只支持 terminate/soft_stop"}), 400 + manager = getattr(terminal, 'sub_agent_manager', None) + if not manager: + return jsonify({"success": False, "error": "子智能体管理器不可用"}), 404 + conversation_id = terminal.context_manager.current_conversation_id + try: + try: + manager._load_state() + except Exception: + pass + manager.reconcile_task_states(conversation_id=conversation_id) + stopped_count = 0 + if mode == 'soft_stop': + stopped_count = manager.soft_stop_all_agents(conversation_id) + debug_log(f"[StopAllSubAgents] soft_stop 会话={conversation_id}, 计数={stopped_count}") + else: + # terminate: 遍历所有非终态子智能体逐个终结 + from modules.sub_agent.state import TERMINAL_STATUSES + for task_info in list(manager.tasks.values()): + if task_info.get('conversation_id') != conversation_id: + continue + status = task_info.get('status') + if status in TERMINAL_STATUSES.union({"terminated"}): + continue + try: + tid = task_info.get('task_id') + manager.terminate_sub_agent(task_id=tid) + stopped_count += 1 + debug_log(f"[StopAllSubAgents] terminated task_id={tid}") + except Exception as exc: + debug_log(f"[StopAllSubAgents] 终止失败 tid={task_info.get('task_id')}, err={exc}") + return jsonify({"success": True, "data": {"stopped_count": stopped_count, "mode": mode}}) + except Exception as exc: + return jsonify({"success": False, "error": str(exc)}), 500 + + @conversation_bp.route('/api/sub_agents//terminate', methods=['POST']) @api_login_required @with_terminal diff --git a/server/tasks/api.py b/server/tasks/api.py index 85d25cb..fc2d24c 100644 --- a/server/tasks/api.py +++ b/server/tasks/api.py @@ -205,9 +205,7 @@ def cancel_task_api(task_id: str): rec = task_manager.get_task(username, task_id) if not rec: return jsonify({"success": False, "error": "任务不存在"}), 404 - force_stop = bool(request.json.get("force_stop", False)) if request.is_json else False - debug_log(f"[TaskCancel][stop_debug] cancel_task_api 收到请求: task_id={task_id}, force_stop={force_stop}, body={request.json if request.is_json else None}") - ok = task_manager.cancel_task(username, task_id, force_stop=force_stop) + ok = task_manager.cancel_task(username, task_id) # 用户取消任务时,一并停止该工作区的目标模式,避免后续新对话继承旧目标。 try: if ok and rec.workspace_id: diff --git a/server/tasks/models.py b/server/tasks/models.py index 042d4f5..102b8cc 100644 --- a/server/tasks/models.py +++ b/server/tasks/models.py @@ -211,19 +211,21 @@ class TaskManager: if rec.username == username and (workspace_id is None or rec.workspace_id == workspace_id) ] - def cancel_task(self, username: str, task_id: str, force_stop: bool = False) -> bool: - debug_log(f"[TaskCancel][stop_debug] === cancel_task 被调 === task_id={task_id}, force_stop={force_stop}") + def cancel_task(self, username: str, task_id: str) -> bool: + """取消主智能体任务。只停主智能体,不触碰后台任务。 + + 后台任务(子智能体、后台指令)的终止/暂停由独立的 API 处理, + 参见 /api/sub_agents/stop_all 和 /api/background_commands/stop_all。 + """ rec = self.get_task(username, task_id) if not rec: - debug_log(f"[TaskCancel][stop_debug] cancel_task 找不到任务: task_id={task_id}") + debug_log(f"[TaskCancel] cancel_task 找不到任务: task_id={task_id}") return False - # 立即快照进入时的状态,后续所有两段式判断都用它。 - # _run_chat_task finally 会在硬取消后并发修改 rec.status, - # 如果后面再读会被误判为第二下。 + task_id = rec.task_id status_at_entry = rec.status - # 先取终端引用,后续多智能体模式与两段式都要用 + # 取终端引用(仅用于停止目标模式等副作用) entry = stop_flags.get(task_id) terminal = None if isinstance(entry, dict): @@ -231,48 +233,32 @@ class TaskManager: if not terminal and rec.workspace_id: try: terminal, _ = get_user_resources(username, rec.workspace_id) - debug_log(f"[TaskCancel][stop_debug] fallback get_user_resources 获取 terminal OK") - except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] fallback get_user_resources 失败: {exc}") + except Exception: + pass - is_ma_mode = bool(getattr(terminal, 'multi_agent_mode', False)) if terminal else False debug_log( - f"[TaskCancel][stop_debug] 入口状态: task_id={task_id}, status={status_at_entry}, " - f"conversation_id={rec.conversation_id}, workspace_id={rec.workspace_id}, " - f"force_stop={force_stop}, multi_agent_mode={is_ma_mode}, terminal_present={bool(terminal)}" + f"[TaskCancel] 入口: task_id={task_id}, status={status_at_entry}, " + f"conv={rec.conversation_id}" ) - # 多智能体模式 force_stop:跳过两段式,直接走强制停止路径 - # 适用场景:第一下已停主智能体,第二下停所有子智能体并重载对话 - if force_stop: - debug_log(f"[TaskCancel][stop_debug] force_stop=true → 走 _force_stop_multi_agent 分支") - return self._force_stop_multi_agent(rec, entry, terminal, username) - debug_log(f"[TaskCancel][stop_debug] force_stop=false → 走两段式分支(可诱导出终端)") - # 1. 硬取消主 asyncio task(如果引用还在) if isinstance(entry, dict): loop = entry.get('loop') task = entry.get('task') - debug_log( - f"[TaskCancel][stop_debug] stop_flags entry: loop_exists={loop is not None}, " - f"task_exists={task is not None}, task_done={task.done() if task else 'n/a'}" - ) if loop and task and not task.done(): try: loop.call_soon_threadsafe(task.cancel) - debug_log(f"[TaskCancel][stop_debug] 已投递硬取消: task_id={task_id}") + debug_log(f"[TaskCancel] 已投递硬取消: task_id={task_id}") except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] 硬取消失败: task_id={task_id}, error={exc}") + debug_log(f"[TaskCancel] 硬取消失败: task_id={task_id}, error={exc}") entry['stop'] = True else: - debug_log(f"[TaskCancel][stop_debug] stop_flags 无 entry,设软标志: task_id={task_id}") stop_flags[task_id] = {'stop': True, 'task': None, 'terminal': None, 'loop': None} entry = stop_flags[task_id] rec.stop_requested = True # 2. 停止目标模式 - workspace = None if rec.workspace_id: try: _, workspace = get_user_resources(username, rec.workspace_id) @@ -289,203 +275,19 @@ class TaskManager: rec.runtime_guidance_queue = [] rec.updated_at = time.time() - # 4. 两段式停止:严格按进入时的 rec.status 判断(快照),不依赖 task.done() 的异步时机。 - # 原因:投递硬取消后 _run_chat_task finally 可能并发把 status 改成 cancel_requested, - # 若此时再读 rec.status 会误判为第二下,导致第一下就误杀后台。 - # - 第一下(running/pending):只硬取消主智能体并置为 cancel_requested,保留后台任务。 - # - 第二下(cancel_requested/stopped/canceled):清理后台任务并置为 stopped。 - # - 额外保护:极短时间内(<500ms)的重复 cancel 请求视为抖动,不执行第二下。 + # 4. 标记为 cancel_requested。 + # _run_chat_task finally 会并发把 status 改为 stopped 并发 task_stopped 事件。 + # 此处仅做软标记,不强制覆盖 finally 即将设的终态。 now = time.time() - is_first_press = status_at_entry in {"running", "pending"} - - if not terminal and rec.workspace_id: - try: - terminal, _ = get_user_resources(username, rec.workspace_id) - except Exception as exc: - debug_log(f"[TaskCancel] 取消任务时重新获取 terminal 失败: {exc}") - - if is_first_press: - # 第一下:主智能体仍在跑,只请求取消,保留后台任务。 - # 注意 1:不要清空 rec.events,否则 _run_chat_task finally 发送的 - # task_stopped 事件会被丢弃,前端无法感知并弹提示。 - # 注意 2:_run_chat_task finally 可能并发先执行并把 status 设为 stopped, - # 此时不要再覆盖回 cancel_requested。 - debug_log(f"[TaskCancel][stop_debug] 第一下分支:ma_mode={is_ma_mode}, 仅取消主智能体") - with self._lock: - if rec.status in {"running", "pending"}: - rec.status = "cancel_requested" - rec.updated_at = now - rec.last_cancel_at = now - debug_log( - f"[TaskCancel] 第一下:请求取消智能体,保留后台任务: " - f"task_id={task_id}, status_at_entry={status_at_entry}, " - f"current_status={rec.status}" - ) - return True - else: - debug_log(f"[TaskCancel][stop_debug] 第二下分支进入,status_at_entry={status_at_entry}, is_ma_mode={is_ma_mode}") - - # 第二下:清理后台任务(过滤 500ms 内的重复请求) - if rec.last_cancel_at is not None and (now - rec.last_cancel_at) < 0.5: - debug_log( - f"[TaskCancel][stop_debug] 500ms 内重复取消,忽略: task_id={task_id}, " - f"elapsed={now - rec.last_cancel_at:.3f}s" - ) - return True - - is_ma_mode = bool(getattr(terminal, "multi_agent_mode", False)) - debug_log(f"[TaskCancel][stop_debug] 准备清理后台任务,is_ma_mode={is_ma_mode}") - - if is_ma_mode: - # 多智能体模式:第二下不应走 terminate 路径! - # 应改为软停止所有子智能体,与 force_stop 一致。 - debug_log(f"[TaskCancel][stop_debug] 多智能体模式第二下分支,转走软停止路径(不是 terminate)") - soft_stop_count = 0 - if terminal: - sub_agent_manager = getattr(terminal, 'sub_agent_manager', None) - if sub_agent_manager: - try: - sub_agent_manager.reconcile_task_states(conversation_id=rec.conversation_id) - soft_stop_count = sub_agent_manager.soft_stop_all_agents(rec.conversation_id) - debug_log(f"[TaskCancel][stop_debug] 软停止 {soft_stop_count} 个子智能体已发出") - except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] soft_stop_all_agents 失败: {exc}") - # 清空主智能体多智能体运行态(与旧逻辑一致) - if terminal and rec.conversation_id: - try: - ma_state = terminal.sub_agent_manager.get_multi_agent_state(rec.conversation_id) - if ma_state: - ma_state.clear() - debug_log(f"[TaskCancel][stop_debug] 多智能体状态已清空: conv={rec.conversation_id}") - except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] 清空多智能体状态失败: {exc}") - has_running_background = False - else: - # 传统模式:第二下继续走 terminate 路径 - debug_log(f"[TaskCancel][stop_debug] 传统模式第二下分支,继续 _cleanup_background_tasks terminate") - has_running_background = self._cleanup_background_tasks(rec, terminal) - with self._lock: - rec.status = "stopped" - rec.updated_at = time.time() - rec.last_cancel_at = time.time() - rec.events.clear() + if rec.status in {"running", "pending"}: + rec.status = "cancel_requested" + rec.updated_at = now + rec.last_cancel_at = now debug_log( - f"[TaskCancel] 第二下:清理后台并停止: task_id={task_id}, " - f"has_running_background={has_running_background}" + f"[TaskCancel] 已取消主智能体: task_id={task_id}, " + f"status_at_entry={status_at_entry}, current_status={rec.status}" ) - - # 多智能体模式:从已保存对话文件恢复,丢弃运行中的半成品状态 - # (调用一半的工具、输出一半的内容等不会保存在对话文件里) - reload_ok = False - messages_count = 0 - if is_ma_mode and terminal and rec.conversation_id: - try: - result = terminal.load_conversation(rec.conversation_id, restore_model=False) - if result.get("success"): - reload_ok = True - messages_count = result.get("messages_count", 0) - debug_log(f"[TaskCancel] 多智能体对话已从文件恢复: conv={rec.conversation_id}, messages={messages_count}") - else: - debug_log(f"[TaskCancel] 多智能体对话恢复失败: {result.get('message', '')}") - except Exception as exc: - debug_log(f"[TaskCancel] 多智能体对话恢复异常: {exc}") - - try: - from server.extensions import socketio - socketio.emit('task_stopped', { - 'message': '任务已停止', - 'reason': 'user_requested', - 'task_id': task_id, - 'conversation_id': rec.conversation_id, - 'has_running_sub_agents': False, - 'has_running_background_commands': False, - 'has_running_multi_agent': False, - 'multi_agent_reloaded': reload_ok, - 'messages_count': messages_count, - }, room=f"user_{username}") - except Exception as exc: - debug_log(f"[TaskCancel] 发送 task_stopped 事件失败: {exc}") - return True - - def _force_stop_multi_agent(self, rec: TaskRecord, entry, terminal, username: str) -> bool: - """多智能体模式强制停止:软停止所有子智能体 → 重载主对话 → 变空闲。 - - 与传统模式第二下的区别: - - 传统模式第二下:terminate 后台子智能体(会终止进程并留下终止记录) - - 多智能体模式:软停止子智能体(完成当前工具后变 idle) - + 从已保存对话文件恢复主对话(丢弃主智能体接收到的半成品输出) - """ - debug_log(f"[TaskCancel][stop_debug] === _force_stop_multi_agent 被调 === task_id={rec.task_id}") - task_id = rec.task_id - - # 1. 先确保主智能体也取消(如果还在跑) - if entry: - entry['stop'] = True - debug_log(f"[TaskCancel][stop_debug] _force_stop_multi_agent entry.stop=True") - rec.stop_requested = True - - # 2. 获取 terminal - if not terminal and rec.workspace_id: - try: - terminal, _ = get_user_resources(username, rec.workspace_id) - debug_log(f"[TaskCancel][stop_debug] _force_stop_multi_agent fallback get terminal OK") - except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] _force_stop_multi_agent fallback get terminal 失败: {exc}") - - # 3. 软停止所有子智能体(不 terminate,让它们在当前工具完成后变 idle) - soft_stop_count = 0 - if terminal: - sub_agent_manager = getattr(terminal, 'sub_agent_manager', None) - if sub_agent_manager: - try: - sub_agent_manager.reconcile_task_states(conversation_id=rec.conversation_id) - soft_stop_count = sub_agent_manager.soft_stop_all_agents(rec.conversation_id) - debug_log(f"[TaskCancel][stop_debug] force_stop 软停止 {soft_stop_count} 个子智能体") - except Exception as exc: - debug_log(f"[TaskCancel][stop_debug] force_stop 软停止子智能体异常: {exc}") - else: - debug_log(f"[TaskCancel][stop_debug] terminal 无 sub_agent_manager,无法软停止") - else: - debug_log(f"[TaskCancel][stop_debug] terminal 为 None,无法软停止") - - # 4. 标记任务已停止 - with self._lock: - rec.status = "stopped" - rec.updated_at = time.time() - rec.events.clear() - - # 5. 从已保存对话文件恢复,丢弃主对话半成品状态 - reload_ok = False - messages_count = 0 - if terminal and rec.conversation_id: - try: - result = terminal.load_conversation(rec.conversation_id, restore_model=False) - if result.get("success"): - reload_ok = True - messages_count = result.get("messages_count", 0) - debug_log(f"[TaskCancel] force_stop 主对话已从文件恢复: conv={rec.conversation_id}, messages={messages_count}") - else: - debug_log(f"[TaskCancel] force_stop 对话恢复失败: {result.get('message', '')}") - except Exception as exc: - debug_log(f"[TaskCancel] force_stop 对话恢复异常: {exc}") - - # 6. 发送 task_stopped 事件通知前端 - try: - from server.extensions import socketio - socketio.emit('task_stopped', { - 'message': '所有子智能体已停止', - 'reason': 'force_stop', - 'task_id': task_id, - 'conversation_id': rec.conversation_id, - 'has_running_sub_agents': False, - 'has_running_background_commands': False, - 'has_running_multi_agent': False, - 'multi_agent_reloaded': reload_ok, - 'messages_count': messages_count, - }, room=f"user_{username}") - except Exception as exc: - debug_log(f"[TaskCancel] force_stop 发送 task_stopped 事件失败: {exc}") return True @staticmethod @@ -766,10 +568,8 @@ class TaskManager: # ---- internal helpers ---- def _cleanup_background_tasks(self, rec: TaskRecord, terminal: Optional[Any]) -> bool: """清理指定任务/对话下的所有后台子智能体和后台命令,返回是否清理到任何任务。""" - debug_log(f"[TaskCancel][stop_debug] _cleanup_background_tasks 被调! conv={rec.conversation_id}") has_running_background = False if not terminal or not rec.conversation_id: - debug_log(f"[TaskCancel][stop_debug] _cleanup_background_tasks 跳过:terminal或conv 不存在") return has_running_background sub_agent_manager = getattr(terminal, 'sub_agent_manager', None) @@ -782,7 +582,6 @@ class TaskManager: status = task_info.get('status') if status not in SUB_AGENT_TERMINAL_STATUSES.union({"terminated"}): has_running_background = True - debug_log(f"[TaskCancel][stop_debug] 调用 terminate_sub_agent: task_id={task_info.get('task_id')}, status={status}") try: sub_agent_manager.terminate_sub_agent(task_id=task_info.get('task_id')) except Exception as exc: diff --git a/static/src/App.vue b/static/src/App.vue index dd0228b..48746e6 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -331,8 +331,10 @@ :multi-agent-mode="multiAgentMode" :has-running-task="composerBusy || anyWorkspaceHasRunningTask" :main-chat-idle="mainChatIdle" + :active-sub-agent-count="activeSubAgentCount" :avatar-status="showStatusAvatar && messages.length > 0 ? avatarStatus : null" @restore-user-question="restoreUserQuestionDialog" + @stop-all-sub-agents="handleStopAllSubAgents" @update:input-message="inputSetMessage" @input-change="handleInputChange" @input-focus="handleInputFocus" diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index a551af5..4da9d48 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -252,6 +252,19 @@ export const computed = { this.compressing ); }, + /** + * 子智能体非终态数(包含 running + idle,不含 failed/completed/terminated/timeout)。 + * 用于状态栏的小按钮显示 “x 个子智能体运行中”。 + */ + activeSubAgentCount() { + const subAgentStore = useSubAgentStore(); + const list = Array.isArray(subAgentStore.subAgents) ? subAgentStore.subAgents : []; + const TERMINAL = new Set(['completed', 'failed', 'timeout', 'terminated']); + return list.filter((s: any) => { + const status = String(s?.status || '').toLowerCase(); + return !TERMINAL.has(status); + }).length; + }, /** * 主对话是否空闲。 * 与 composerBusy 的区别:composerBusy=true 可能是“主对话空闲但后台子智能体在跑” diff --git a/static/src/app/methods/message/send.ts b/static/src/app/methods/message/send.ts index 10952fa..61dcffb 100644 --- a/static/src/app/methods/message/send.ts +++ b/static/src/app/methods/message/send.ts @@ -36,7 +36,6 @@ export const sendMethods = { const mainIdle = typeof this.mainChatIdle === 'function' ? this.mainChatIdle : ( !this.streamingUi && !this.stopRequested && !this.compressionInProgress && !this.compressing ); - console.log('[STOP_DEBUG] handleSendOrStop 状态 → composerBusy:', this.composerBusy, 'mainIdle:', mainIdle, 'hasText:', hasText, 'hasMedia:', hasMedia, 'waitingForSubAgent:', this.waitingForSubAgent); if (this.composerBusy && mainIdle && hasText) { // 如果有 pending 问题(子智能体询问主智能体),仍走问答路径,不走直接发送 if (Array.isArray(this.pendingUserQuestions) && this.pendingUserQuestions.length > 0) { @@ -49,7 +48,7 @@ export const sendMethods = { } return; } - console.log('[STOP_DEBUG] 主对话空闲但后台子智能体在跑,直接发送新消息触发主智能体下一轮'); + // 主对话空闲但后台任务在跑:直接发送新消息触发主智能体下一轮 this.sendMessage(); return; } @@ -426,16 +425,9 @@ export const sendMethods = { return; } - // 多智能体模式下,stopRequested 不阻止第二次点击——第二次点击是强制停止子智能体 - const isMultiAgent = !!this.multiAgentMode; - const canStop = isMultiAgent - ? (this.composerBusy || this.stopRequested) - : (this.composerBusy && !this.stopRequested); - - // 多智能体模式下,如果主智能体已经停了(stopRequested=true),第二次点击是 force stop - const isForceStop = isMultiAgent && this.stopRequested; - - console.log('[STOP_DEBUG] stopTask 状态快照 → isMultiAgent:', isMultiAgent, 'composerBusy:', this.composerBusy, 'stopRequested:', this.stopRequested, 'taskInProgress:', this.taskInProgress, 'canStop:', canStop, 'isForceStop:', isForceStop); + // 停止按钮现在只停主智能体,与后台任务无关。 + // canStop 判断只看主智能体是否在 streaming。多次点击被 stopRequested 抖动拦截。 + const canStop = this.streamingUi && !this.stopRequested; goalModeDebugLog('stopTask:entry', { composerBusy: this.composerBusy, @@ -443,8 +435,6 @@ export const sendMethods = { taskInProgress: this.taskInProgress, streamingUi: this.streamingUi, canStop, - isForceStop, - isMultiAgent, currentTaskId: this.currentTaskId, }); if (!canStop) { @@ -470,71 +460,37 @@ export const sendMethods = { const taskStore = useTaskStore(); if (taskStore.currentTaskId) { - await taskStore.cancelTask(isForceStop); + await taskStore.cancelTask(); } // 等待后端确认;轮询继续,task_stopped 事件到达后会由 taskStore 自动停止轮询 await new Promise((resolve) => setTimeout(resolve, 300)); - // 多智能体模式强制停止后,直接释放所有状态,等待 task_stopped 事件处理重载 - if (isForceStop) { - this.clearPendingTools('user_stop'); - this.streamingMessage = false; - this.taskInProgress = false; - this.forceUnlockMonitor('user_stop'); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - this.stopMultiAgentTaskProbe(); - this.stopWaitingTaskProbe(); - const lastMessage = this.messages[this.messages.length - 1]; - if (lastMessage && lastMessage.role === 'assistant') { - lastMessage.awaitingFirstContent = false; - lastMessage.generatingLabel = ''; - } - console.log('[DEBUG_AWAITING] stopTask force stop 清理完成'); - } else { - const shouldKeepBusy = - ['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus)); + const shouldKeepBusy = + ['running', 'pending', 'cancel_requested', 'canceled'].includes(String(taskStore.taskStatus)); - goalModeDebugLog('stopTask:try-end', { - currentTaskId: taskStore.currentTaskId, - taskStatus: taskStore.taskStatus, - shouldKeepBusy, - streamingMessage: this.streamingMessage, - }); + goalModeDebugLog('stopTask:try-end', { + currentTaskId: taskStore.currentTaskId, + taskStatus: taskStore.taskStatus, + shouldKeepBusy, + streamingMessage: this.streamingMessage, + }); - // 清理前端状态 - this.clearPendingTools('user_stop'); - this.streamingMessage = false; - // 若后台已回传停止事件,不要再次把输入区锁回“停止中” - this.taskInProgress = shouldKeepBusy; - this.forceUnlockMonitor('user_stop'); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } + // 清理前端状态 + this.clearPendingTools('user_stop'); + this.streamingMessage = false; + // 若后台已回传停止事件,不要再次把输入区锁回“停止中” + this.taskInProgress = shouldKeepBusy; + this.forceUnlockMonitor('user_stop'); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); + } - // 清理assistant消息的等待动画状态 - const lastMessage = this.messages[this.messages.length - 1]; - const before = { - hasLastMessage: !!lastMessage, - role: lastMessage?.role, - awaitingFirstContent: lastMessage?.awaitingFirstContent, - generatingLabel: lastMessage?.generatingLabel - }; - - if (lastMessage && lastMessage.role === 'assistant') { - lastMessage.awaitingFirstContent = false; - lastMessage.generatingLabel = ''; - } - - console.log('[DEBUG_AWAITING] stopTask 清理完成', { - before, - after: { - awaitingFirstContent: lastMessage?.awaitingFirstContent, - generatingLabel: lastMessage?.generatingLabel - } - }); + // 清理assistant消息的等待动画状态 + const lastMessage = this.messages[this.messages.length - 1]; + if (lastMessage && lastMessage.role === 'assistant') { + lastMessage.awaitingFirstContent = false; + lastMessage.generatingLabel = ''; } } catch (error) { console.error('[Message] 取消任务失败:', error); @@ -569,7 +525,7 @@ export const sendMethods = { this.uiPushToast({ title: '停止请求已发送', - message: '若任务未停止,请再次点击停止按钮', + message: '若主对话未停止,请稍候;后台任务可通过状态栏单独停止', type: 'info' }); } finally { diff --git a/static/src/app/methods/ui/mode.ts b/static/src/app/methods/ui/mode.ts index 84cea8b..ab5aa36 100644 --- a/static/src/app/methods/ui/mode.ts +++ b/static/src/app/methods/ui/mode.ts @@ -180,5 +180,55 @@ export const modeMethods = { }, async toggleThinkingMode() { await this.handleCycleRunMode(); + }, + async handleStopAllSubAgents() { + const isMultiAgent = !!this.multiAgentMode; + const mode = isMultiAgent ? 'soft_stop' : 'terminate'; + const title = isMultiAgent ? '是否暂停所有子智能体?' : '是否终结所有子智能体?'; + const message = isMultiAgent + ? '所有正在运行的子智能体将停止工作并变为空闲状态。取消表示不执行操作。' + : '所有后台子智能体将被强制终止。取消表示不执行操作。'; + const confirmText = isMultiAgent ? '暂停' : '终结'; + let proceed = false; + try { + const uiStore = (await import('../../../stores/ui')).useUiStore(); + proceed = await uiStore.requestConfirm({ + title, + message, + confirmText, + cancelText: '取消', + confirmVariant: 'danger', + closeOnBackdrop: true + }); + } catch (error) { + console.error('[stopAllSubAgents] 弹窗异常:', error); + return; + } + if (!proceed) return; + try { + const { useSubAgentStore } = await import('../../../stores/subAgent'); + const store = useSubAgentStore(); + const result = await store.stopAllAgents(mode); + if (!result.success) { + this.uiPushToast({ + title: '停止子智能体失败', + message: result.error || '请重试', + type: 'error' + }); + return; + } + this.uiPushToast({ + title: isMultiAgent ? '子智能体已暂停' : '子智能体已终结', + message: `已处理 ${result.stoppedCount || 0} 个子智能体`, + type: 'info' + }); + } catch (error: any) { + console.error('[stopAllSubAgents] 调用失败:', error); + this.uiPushToast({ + title: '停止子智能体失败', + message: error?.message || String(error), + type: 'error' + }); + } } }; diff --git a/static/src/components/input/InputComposer.vue b/static/src/components/input/InputComposer.vue index a2719ac..19f0d27 100644 --- a/static/src/components/input/InputComposer.vue +++ b/static/src/components/input/InputComposer.vue @@ -177,6 +177,14 @@ > 等待回答问题 + + @@ -415,6 +425,20 @@ const openBackgroundCommand = (command: any) => { backgroundCommandStore.openCommand(command); }; +const BG_COMMAND_TERMINAL = new Set(['completed', 'failed', 'timeout', 'cancelled']); +const runningBackgroundCommandCount = computed(() => { + return (backgroundCommands.value || []).filter((c: any) => { + const s = String(c?.status || '').toLowerCase(); + return !BG_COMMAND_TERMINAL.has(s); + }).length; +}); +const handleStopAllCommands = async () => { + const result = await backgroundCommandStore.stopAllCommands(); + if (!result.success) { + console.error('[LeftPanel] 批量停止后台指令失败:', result.error); + } +}; + const formatBackgroundCommandMeta = (command: any) => { const code = command?.return_code; if (code === null || code === undefined) { diff --git a/static/src/composables/useLegacySocket.ts b/static/src/composables/useLegacySocket.ts index 9e43462..a28f125 100644 --- a/static/src/composables/useLegacySocket.ts +++ b/static/src/composables/useLegacySocket.ts @@ -1642,59 +1642,22 @@ export async function initializeLegacySocket(ctx: any) { // 任务停止 ctx.socket.on('task_stopped', (data) => { socketLog('任务已停止:', data.message); - console.log('[STOP_DEBUG] 收到 task_stopped 事件:', JSON.stringify({ - message: data.message, - reason: data.reason, - task_id: data.task_id, - has_running_sub_agents: data.has_running_sub_agents, - has_running_background_commands: data.has_running_background_commands, - has_running_multi_agent: data.has_running_multi_agent, - multi_agent_reloaded: data.multi_agent_reloaded, - multiAgentMode: ctx.multiAgentMode, - stopRequested_before: ctx.stopRequested, - taskInProgress_before: ctx.taskInProgress, - composerBusy: ctx.composerBusy - })); const hasRunningSubAgents = !!data?.has_running_sub_agents; const hasRunningBackgroundCommands = !!data?.has_running_background_commands; const hasRunningBackground = hasRunningSubAgents || hasRunningBackgroundCommands; const hasRunningMultiAgent = !!data?.has_running_multi_agent; - const multiAgentReloaded = !!data?.multi_agent_reloaded; - - // 多智能体模式下,第二下停止后后端已从文件恢复对话,前端需要重新加载对话内容 - if (multiAgentReloaded && ctx.currentConversationId) { - console.log('[DEBUG] 多智能体对话已从文件恢复,前端重新加载'); - // 触发前端重新加载对话内容(加载已保存的完整消息列表,丢弃半成品状态) - if (typeof ctx.loadConversation === 'function') { - ctx.loadConversation(ctx.currentConversationId, { force: true }); - } else { - // 回退:直接调 API 重新加载 - fetch(`/api/conversations/${ctx.currentConversationId}/load`, { method: 'PUT' }) - .then(r => r.json()) - .then(result => { - if (result.success && result.messages_count !== undefined) { - console.log('[DEBUG] 对话重新加载成功,消息数:', result.messages_count); - } - }) - .catch(err => console.error('[DEBUG] 对话重新加载失败:', err)); - } + // 主对话已被停止。streaming、stopRequested 都重置。 + // 如果仍有后台任务在跑,保留 taskInProgress=true 让对话在列表里仍显示“运行中” + // (仅影响 UI 标识,不影响输入栏可用性——输入栏只看 streamingMessage)。 + const stillHasBackground = hasRunningBackground || hasRunningMultiAgent; + ctx.streamingMessage = false; + ctx.stopRequested = false; + ctx.waitingForSubAgent = hasRunningSubAgents; + ctx.waitingForBackgroundCommand = hasRunningBackgroundCommands; + ctx.taskInProgress = stillHasBackground; + if (typeof ctx.scheduleResetAfterTask === 'function') { + ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true }); } - - // 多智能体模式下,主任务停止即可恢复输入区,不因子智能体后台运行保持停止按钮 - if (ctx.multiAgentMode || !hasRunningBackground) { - ctx.taskInProgress = false; - } else { - ctx.waitingForSubAgent = hasRunningSubAgents; - ctx.waitingForBackgroundCommand = hasRunningBackgroundCommands; - if (typeof ctx.uiPushToast === 'function') { - ctx.uiPushToast({ - title: '智能体已停止', - message: '再次按下停止按钮以结束后台任务', - type: 'info' - }); - } - } - ctx.scheduleResetAfterTask('socket:task_stopped', { preserveMonitorWindows: true }); }); // 任务完成(重点:更新Token统计) diff --git a/static/src/stores/subAgent.ts b/static/src/stores/subAgent.ts index c7d99c6..2e1aa62 100644 --- a/static/src/stores/subAgent.ts +++ b/static/src/stores/subAgent.ts @@ -184,6 +184,24 @@ export const useSubAgentStore = defineStore('subAgent', { this.stoppingTaskIds = next; } }, + async stopAllAgents(mode: 'terminate' | 'soft_stop'): Promise<{ success: boolean; stoppedCount?: number; error?: string }> { + try { + const resp = await fetch('/api/sub_agents/stop_all', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ mode }) + }); + const data = await resp.json().catch(() => ({})); + if (!resp.ok || !data?.success) { + throw new Error(data?.error || `HTTP ${resp.status}`); + } + await this.fetchSubAgents(); + return { success: true, stoppedCount: data?.data?.stopped_count || 0 }; + } catch (error: any) { + const message = error?.message || String(error); + return { success: false, error: message }; + } + }, stripConversationPrefix(conversationId: string) { if (!conversationId) return ''; return conversationId.startsWith('conv_') ? conversationId.slice(5) : conversationId; diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index e760072..7f7599d 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -538,19 +538,17 @@ export const useTaskStore = defineStore('task', { this.currentTaskId = null; // 清除任务 ID }, - async cancelTask(forceStop: boolean = false) { + async cancelTask() { if (!this.currentTaskId) { return; } try { - debugLog('[Task] 取消任务:', this.currentTaskId, 'forceStop:', forceStop); - console.log('[STOP_DEBUG] cancelTask 发送请求 → taskId:', this.currentTaskId, 'forceStop:', forceStop); + debugLog('[Task] 取消任务:', this.currentTaskId); const response = await fetch(`/api/tasks/${this.currentTaskId}/cancel`, { method: 'POST', - headers: { 'Content-Type': 'application/json' }, - body: JSON.stringify({ force_stop: forceStop }) + headers: { 'Content-Type': 'application/json' } }); if (!response.ok) { diff --git a/static/src/styles/components/panels/_left-panel.scss b/static/src/styles/components/panels/_left-panel.scss index 21434ca..8dd4078 100644 --- a/static/src/styles/components/panels/_left-panel.scss +++ b/static/src/styles/components/panels/_left-panel.scss @@ -998,3 +998,28 @@ .panel-menu button.active { background: var(--theme-tab-active); } + +/* 后台指令停止按钮 */ +.background-stop-btn { + display: inline-flex; + align-items: center; + gap: 4px; + height: 28px; + padding: 0 10px; + border: 1px solid var(--claude-border); + border-radius: 10px; + background: transparent; + color: var(--state-error); + font-size: 12px; + line-height: 1.2; + cursor: pointer; + transition: background 140ms ease, color 140ms ease; +} + +.background-stop-btn:hover { + background: color-mix(in srgb, var(--state-error) 12%, transparent); +} + +.background-stop-label { + font-weight: 500; +}