From 03cb39924a3f8f3ac3c3cbe8dd69017cd440b956 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 29 May 2026 17:54:11 +0800 Subject: [PATCH] feat(host): support concurrent workspace conversations Implement host-mode multi-workspace concurrency while preserving one active task per workspace. Host terminals are scoped by workspace id, host workspace switching no longer globally refreshes or blocks on tasks, and task APIs now expose workspace/conversation metadata for frontend coordination. Update conversation navigation so creating/loading conversations during an active workspace task is view-only and does not mutate backend terminal context. This prevents running task output from being pushed into the currently viewed conversation after switching or creating a new conversation. Add running task awareness to the frontend sidebar, composer, and polling flow. Running tasks can be shown across workspaces, current-workspace active conversations render inline loaders, completed unviewed tasks persist as check indicators across refresh, and completed tasks clear once viewed. Restore running conversations correctly after switching workspace/conversation or refreshing during an active task. REST polling is rebound only for the matching conversation/task, stale poll responses are ignored, empty assistant placeholders are restored before first content, and completion clears loader/stop states without requiring page refresh. Adjust host workspace UX so switching workspaces loads the active conversation if one is running, otherwise opens the new-conversation state. Other conversations in a busy workspace are view-only with disabled input instead of blocked navigation. Validation: python3 -m py_compile server/auth.py server/context.py server/conversation.py server/status.py server/tasks.py; python3 -m unittest test.test_server_refactor_smoke; npm run build --silent 2>&1 | tail -n 12. --- server/auth.py | 7 +- server/context.py | 19 +- server/conversation.py | 159 +++++++--- server/status.py | 93 ++---- server/tasks.py | 97 +++++- static/src/App.vue | 8 +- static/src/app/computed.ts | 23 +- static/src/app/methods/conversation.ts | 117 ++------ static/src/app/methods/message.ts | 9 + static/src/app/methods/taskPolling.ts | 224 +++++++++++--- static/src/app/methods/ui.ts | 275 +++++++++++++++++- static/src/app/state.ts | 2 + static/src/components/panels/LeftPanel.vue | 7 + .../sidebar/ConversationSidebar.vue | 84 +++++- static/src/stores/conversation.ts | 8 +- static/src/stores/task.ts | 24 +- .../styles/components/panels/_left-panel.scss | 12 + .../components/sidebar/_conversation.scss | 107 ++++++- 18 files changed, 1011 insertions(+), 264 deletions(-) diff --git a/server/auth.py b/server/auth.py index 1712007..ec2244a 100644 --- a/server/auth.py +++ b/server/auth.py @@ -241,7 +241,12 @@ def host_login(): # 预先创建宿主机模式的终端/容器句柄(host 模式不会启动 Docker) try: - state.container_manager.ensure_container("host", str(host_path), container_key="host", preferred_mode="host") + state.container_manager.ensure_container( + "host", + str(host_path), + container_key=f"host::{host_workspace_id}", + preferred_mode="host", + ) except RuntimeError as exc: session.clear() return jsonify({"success": False, "error": str(exc)}), 503 diff --git a/server/context.py b/server/context.py index 1c12720..9d1f76b 100644 --- a/server/context.py +++ b/server/context.py @@ -90,15 +90,25 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st host_mode_session = bool(session.get("host_mode")) if has_request_context() else False sandbox_is_host = (TERMINAL_SANDBOX_MODE or "host").lower() == "host" if host_mode_session and sandbox_is_host: - selected_workspace_id = session.get("host_workspace_id") if has_request_context() else None + # 宿主机多工作区并行:资源选择必须优先由显式 workspace_id / 当前请求 session 决定, + # 不能被进程级 HOST_ACTIVE_WORKSPACE_ID 覆盖,否则后台任务会在用户切换视图后串到新工作区。 + selected_workspace_id = ( + workspace_id + or (session.get("host_workspace_id") if has_request_context() else None) + or (session.get("workspace_id") if has_request_context() else None) + ) with state.HOST_ACTIVE_WORKSPACE_LOCK: active_workspace_id = state.HOST_ACTIVE_WORKSPACE_ID active_workspace_path = state.HOST_ACTIVE_WORKSPACE_PATH active_workspace_version = state.HOST_ACTIVE_WORKSPACE_VERSION - if active_workspace_id: + if not selected_workspace_id and active_workspace_id: selected_workspace_id = active_workspace_id _, host_workspace = resolve_host_workspace(selected_workspace_id) - if active_workspace_id and active_workspace_path: + if ( + active_workspace_id + and active_workspace_path + and selected_workspace_id == active_workspace_id + ): host_workspace = dict(host_workspace) host_workspace["workspace_id"] = active_workspace_id host_workspace["path"] = active_workspace_path @@ -139,7 +149,8 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st if not hasattr(workspace, "workspace_id"): workspace.workspace_id = host_workspace.get("workspace_id") or "default" - term_key = "host" + workspace_id_value = getattr(workspace, "workspace_id", None) or host_workspace.get("workspace_id") or "default" + term_key = _make_terminal_key("host", workspace_id_value) container_handle = state.container_manager.ensure_container("host", str(project_path), container_key=term_key, preferred_mode="host") usage_tracker = None # 宿主机模式不计配额 terminal = state.user_terminals.get(term_key) diff --git a/server/conversation.py b/server/conversation.py index 18399d5..8bc3d62 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -153,6 +153,49 @@ def _cancel_running_tasks(username: str, workspace_id: str, timeout_seconds: flo return canceled, False +def _get_active_workspace_task(username: str, workspace_id: str): + """返回当前工作区仍在运行/停止中的主任务。用于避免视图导航改写运行任务的 terminal 上下文。""" + try: + from .tasks import task_manager + active_statuses = {"pending", "running", "cancel_requested"} + tasks = [ + rec for rec in task_manager.list_tasks(username, workspace_id) + if getattr(rec, "status", None) in active_statuses + ] + tasks.sort(key=lambda rec: getattr(rec, "created_at", 0), reverse=True) + return tasks[0] if tasks else None + except Exception as exc: + debug_log(f"[ConversationSafeNav] 查询运行任务失败: {exc}") + return None + + +def _build_safe_load_result(terminal: WebTerminal, conversation_id: str) -> Dict[str, Any]: + """只读取对话元数据,不调用 terminal.load_conversation,避免修改运行任务正在使用的上下文。""" + normalized_id = _normalize_conv_id(conversation_id) + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + data = cm.load_conversation(normalized_id) if cm else None + if not data: + return { + "success": False, + "error": "对话不存在或加载失败", + "message": f"对话加载失败: {normalized_id}", + } + meta = data.get("metadata", {}) or {} + run_mode = meta.get("run_mode") or getattr(terminal, "run_mode", "fast") + thinking_mode = bool(meta.get("thinking_mode", run_mode != "fast")) + return { + "success": True, + "conversation_id": normalized_id, + "title": data.get("title", "未知对话"), + "messages_count": len(data.get("messages", []) or []), + "run_mode": run_mode, + "thinking_mode": thinking_mode, + "model_key": meta.get("model_key") or getattr(terminal, "model_key", None), + "message": f"对话已加载: {normalized_id}", + "safe_navigation": True, + } + + def _normalize_conv_id(conversation_id: str) -> str: conv = (conversation_id or "").strip() if not conv: @@ -393,19 +436,49 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam thinking_mode = data.get('thinking_mode') if preserve_mode and 'thinking_mode' in data else None run_mode = data.get('mode') if preserve_mode and 'mode' in data else None + # 多工作区并行后,新建/切换对话只是前端视图导航,不应停止同工作区正在运行的主任务。 + # 同工作区单任务互斥由 /api/tasks 创建任务时兜底限制;输入框禁用由前端根据任务状态处理。 workspace_id = session.get("workspace_id") or "default" - canceled_count, settled = _cancel_running_tasks(username=username, workspace_id=workspace_id) - if canceled_count: - debug_log(f"[TaskCancel] 创建新对话前取消任务: {canceled_count}") - if not settled: - return jsonify({ - "success": False, - "error": "任务正在停止中,请稍后再试", - "message": "检测到后台任务仍在停止,请稍后再创建新对话。" - }), 409 - - _terminate_running_sub_agents(terminal, reason="用户创建新对话") - result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode) + active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id) + if active_task: + # 运行中时只能创建“视图用”的新对话文件,不能调用 terminal.create_new_conversation。 + # 后者会切换 context_manager.current_conversation_id,导致运行任务后续内容串写到新对话。 + cm = getattr(getattr(terminal, "context_manager", None), "conversation_manager", None) + if not cm: + return jsonify({"success": False, "error": "对话管理器未初始化"}), 500 + try: + prefs = load_personalization_config(workspace.data_dir) + except Exception: + prefs = {} + safe_run_mode = run_mode + if safe_run_mode not in {"fast", "thinking", "deep"}: + candidate = (prefs or {}).get("default_run_mode") + safe_run_mode = candidate if candidate in {"fast", "thinking", "deep"} else "fast" + safe_thinking = bool(thinking_mode) if thinking_mode is not None else safe_run_mode != "fast" + previous_cm_current = getattr(cm, "current_conversation_id", None) + conversation_id = cm.create_conversation( + project_path=str(workspace.project_path), + thinking_mode=safe_thinking, + run_mode=safe_run_mode, + initial_messages=[], + model_key=(prefs or {}).get("default_model") or getattr(terminal, "model_key", None), + metadata_overrides={ + "permission_mode": getattr(terminal, "get_permission_mode", lambda: "unrestricted")(), + "execution_mode": getattr(terminal, "get_execution_mode", lambda: "sandbox")(), + }, + ) + try: + cm.current_conversation_id = previous_cm_current + except Exception: + pass + result = { + "success": True, + "conversation_id": conversation_id, + "message": f"已创建新对话: {conversation_id}", + "safe_navigation": True, + } + else: + result = terminal.create_new_conversation(thinking_mode=thinking_mode, run_mode=run_mode) if result["success"]: session['run_mode'] = terminal.run_mode @@ -416,11 +489,12 @@ def create_conversation(terminal: WebTerminal, workspace: UserWorkspace, usernam 'conversation_id': result["conversation_id"] }, room=f"user_{username}") - # 广播当前对话切换事件 - socketio.emit('conversation_changed', { - 'conversation_id': result["conversation_id"], - 'title': "新对话" - }, room=f"user_{username}") + if not result.get("safe_navigation"): + # 安全导航创建不代表后端 terminal 当前对话已切换,因此不广播 conversation_changed。 + socketio.emit('conversation_changed', { + 'conversation_id': result["conversation_id"], + 'title': "新对话" + }, room=f"user_{username}") return jsonify(result), 201 else: @@ -479,20 +553,14 @@ def get_conversation_info(terminal: WebTerminal, workspace: UserWorkspace, usern def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWorkspace, username: str): """加载特定对话""" try: - current_id = getattr(getattr(terminal, "context_manager", None), "current_conversation_id", None) - if current_id and current_id != conversation_id: - workspace_id = session.get("workspace_id") or "default" - canceled_count, settled = _cancel_running_tasks(username=username, workspace_id=workspace_id) - if canceled_count: - debug_log(f"[TaskCancel] 切换对话前取消任务: {canceled_count}") - if not settled: - return jsonify({ - "success": False, - "error": "任务正在停止中,请稍后再试", - "message": "检测到后台任务仍在停止,请稍后再切换对话。" - }), 409 - _terminate_running_sub_agents(terminal, reason="用户切换对话") - result = terminal.load_conversation(conversation_id) + workspace_id = session.get("workspace_id") or "default" + active_task = _get_active_workspace_task(username=username, workspace_id=workspace_id) + if active_task: + # 同工作区有任务运行时,加载/查看其他对话不能改后端 terminal 当前上下文。 + # 否则运行任务后续保存与事件归属会被切到当前查看的对话。 + result = _build_safe_load_result(terminal, conversation_id) + else: + result = terminal.load_conversation(conversation_id) if result["success"]: session['run_mode'] = terminal.run_mode @@ -540,22 +608,23 @@ def load_conversation(conversation_id, terminal: WebTerminal, workspace: UserWor "error": str(exc), } - # 广播对话切换事件 - socketio.emit('conversation_changed', { - 'conversation_id': conversation_id, - 'title': result.get("title", "未知对话"), - 'messages_count': result.get("messages_count", 0) - }, room=f"user_{username}") + if not result.get("safe_navigation"): + # 安全导航只改变前端查看对象,不代表后端 terminal 当前上下文已切换。 + socketio.emit('conversation_changed', { + 'conversation_id': conversation_id, + 'title': result.get("title", "未知对话"), + 'messages_count': result.get("messages_count", 0) + }, room=f"user_{username}") - # 广播系统状态更新(因为当前对话改变了) - status = terminal.get_status() - socketio.emit('status_update', status, room=f"user_{username}") + # 广播系统状态更新(因为当前对话改变了) + status = terminal.get_status() + socketio.emit('status_update', status, room=f"user_{username}") - # 清理和重置相关UI状态 - socketio.emit('conversation_loaded', { - 'conversation_id': conversation_id, - 'clear_ui': True # 提示前端清理当前UI状态 - }, room=f"user_{username}") + # 清理和重置相关UI状态 + socketio.emit('conversation_loaded', { + 'conversation_id': conversation_id, + 'clear_ui': True # 提示前端清理当前UI状态 + }, room=f"user_{username}") return jsonify(result) else: diff --git a/server/status.py b/server/status.py index 991bb5f..a65edeb 100644 --- a/server/status.py +++ b/server/status.py @@ -149,7 +149,12 @@ def get_status(terminal, workspace, username): try: # 首屏状态只需要容器是否存在/运行等轻量信息;Docker stats 很慢, # 由 /api/container-status 在用量面板需要时单独拉取。 - status['container'] = container_manager.get_container_status(username, include_stats=False) + container_key = ( + f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" + if bool(session.get("host_mode")) or username == "host" + else username + ) + status['container'] = container_manager.get_container_status(container_key, include_stats=False) except Exception as exc: status['container'] = {"success": False, "error": str(exc)} log_conn_diag(f"status container-status-failed user={username} error={exc}") @@ -194,13 +199,25 @@ def list_host_workspaces(): session['host_workspace_id'] = current_id session['workspace_id'] = current_id + running_by_workspace = {} + try: + from .tasks import task_manager + for rec in task_manager.list_tasks("host"): + if rec.status in {"pending", "running", "cancel_requested"}: + ws_id = getattr(rec, "workspace_id", None) or "default" + running_by_workspace[ws_id] = running_by_workspace.get(ws_id, 0) + 1 + except Exception: + running_by_workspace = {} + workspaces = [] for item in catalog.get("workspaces") or []: + ws_id = item.get("workspace_id") workspaces.append({ - "workspace_id": item.get("workspace_id"), + "workspace_id": ws_id, "label": item.get("label"), "path": item.get("path"), - "is_current": item.get("workspace_id") == current_id, + "is_current": ws_id == current_id, + "running_task_count": int(running_by_workspace.get(ws_id, 0)), }) return jsonify({ @@ -250,60 +267,6 @@ def select_host_workspace(): current_workspace_id=current_id, ) - if current_id == workspace_id: - with state.HOST_ACTIVE_WORKSPACE_LOCK: - state.HOST_ACTIVE_WORKSPACE_ID = workspace_id - state.HOST_ACTIVE_WORKSPACE_PATH = target_path - state.HOST_ACTIVE_WORKSPACE_VERSION += 1 - session['workspace_id'] = workspace_id - try: - container_handle = state.container_manager.ensure_container( - "host", - target_path, - container_key="host", - preferred_mode="host", - ) - host_terminal = state.user_terminals.get("host") - if host_terminal: - host_terminal.update_project_path(target_path) - host_terminal.update_container_session(container_handle) - attach_user_broadcast(host_terminal, "host") - host_terminal.username = "host" - host_terminal.user_role = "admin" - write_host_workspace_debug( - "status.select_host_workspace.same_id_reapply", - terminal_id=id(host_terminal), - workspace_id=workspace_id, - project_path=str(getattr(host_terminal, "project_path", "")), - context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")), - ) - except Exception: - pass - return jsonify({ - "success": True, - "data": { - "current_workspace_id": workspace_id, - "project_path": target_path, - "default_workspace_id": catalog.get("default_workspace_id"), - "reloaded": False, - } - }) - - try: - from .tasks import task_manager - - running = [ - rec for rec in task_manager.list_tasks("host") - if rec.status in {"pending", "running", "cancel_requested"} - ] - if running: - return jsonify({ - "success": False, - "error": "存在运行中的任务,请先停止后再切换工作区", - }), 409 - except Exception: - pass - previous_workspace_id = session.get("workspace_id") with state.HOST_ACTIVE_WORKSPACE_LOCK: state.HOST_ACTIVE_WORKSPACE_ID = workspace_id @@ -316,7 +279,7 @@ def select_host_workspace(): container_handle = state.container_manager.ensure_container( "host", target_path, - container_key="host", + container_key=f"host::{workspace_id}", preferred_mode="host", ) except RuntimeError as exc: @@ -334,10 +297,9 @@ def select_host_workspace(): ) return jsonify({"success": False, "error": str(exc)}), 503 - host_terminal = state.user_terminals.get("host") + host_terminal = state.user_terminals.get(f"host::{workspace_id}") if host_terminal: try: - host_terminal.update_project_path(target_path) host_terminal.update_container_session(container_handle) attach_user_broadcast(host_terminal, "host") host_terminal.username = "host" @@ -356,7 +318,7 @@ def select_host_workspace(): host_terminal.terminal_manager.close_all() except Exception: pass - state.user_terminals.pop("host", None) + state.user_terminals.pop(f"host::{workspace_id}", None) write_host_workspace_debug( "status.select_host_workspace.drop_terminal_after_error", workspace_id=workspace_id, @@ -373,7 +335,7 @@ def select_host_workspace(): "current_workspace_id": workspace_id, "project_path": target_path, "default_workspace_id": catalog.get("default_workspace_id"), - "reloaded": True, + "reloaded": current_id != workspace_id, } }) @@ -439,7 +401,12 @@ def create_host_workspace_api(): @with_terminal def get_container_status_api(terminal, workspace, username): try: - status = container_manager.get_container_status(username) + container_key = ( + f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" + if bool(session.get("host_mode")) or username == "host" + else username + ) + status = container_manager.get_container_status(container_key) return jsonify({"success": True, "data": status}) except Exception as exc: return jsonify({"success": False, "error": str(exc)}), 500 diff --git a/server/tasks.py b/server/tasks.py index 854f42b..0da9a67 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -1,10 +1,12 @@ """简单任务 API:将聊天任务与 WebSocket 解耦,支持后台运行与轮询。""" from __future__ import annotations import mimetypes +import json import time import threading import uuid from collections import deque +from pathlib import Path from typing import Dict, Any, Optional, List from flask import Blueprint, request, jsonify @@ -16,6 +18,7 @@ from .chat_flow import run_chat_task_sync from .state import stop_flags from .utils_common import debug_log, log_conn_diag from utils.host_workspace_debug import write_host_workspace_debug +from config import DATA_DIR class TaskRecord: @@ -125,7 +128,7 @@ class TaskManager: # 单工作区互斥:禁止同一用户同一工作区并发任务 existing = [t for t in self.list_tasks(username, workspace_id) if t.status in {"pending", "running"}] if existing: - raise RuntimeError("已有运行中的任务,请稍后再试。") + raise RuntimeError("当前工作区已有运行中的任务,请稍后再试。") task_id = str(uuid.uuid4()) record = TaskRecord(task_id, username, workspace_id, message, conversation_id, model_key, thinking_mode, run_mode, max_iterations) # 记录当前 session 快照,便于后台线程内使用 @@ -484,6 +487,13 @@ class TaskManager: # ---- internal helpers ---- def _append_event(self, rec: TaskRecord, event_type: str, data: Dict[str, Any]): + if isinstance(data, dict): + data = dict(data) + data.setdefault("task_id", rec.task_id) + if rec.conversation_id: + data.setdefault("conversation_id", rec.conversation_id) + if rec.workspace_id: + data.setdefault("workspace_id", rec.workspace_id) with self._lock: idx = getattr(rec, "next_event_idx", None) if idx is None: @@ -601,6 +611,13 @@ class TaskManager: debug_log(f"[Task] 注入 user_message 事件失败: {exc}") def sender(event_type, data): + if isinstance(data, dict): + data = dict(data) + data.setdefault("task_id", rec.task_id) + if rec.conversation_id: + data.setdefault("conversation_id", rec.conversation_id) + if rec.workspace_id: + data.setdefault("workspace_id", rec.workspace_id) # 记录事件 self._append_event(rec, event_type, data) # 在线用户仍然收到实时推送(房间 user_{username}) @@ -720,23 +737,79 @@ def start_task_cleanup_scheduler(): start_task_cleanup_scheduler() +def _conversation_title_for_task(rec: TaskRecord) -> Optional[str]: + conv_id = (getattr(rec, "conversation_id", None) or "").strip() + if not conv_id: + return None + if not conv_id.startswith("conv_"): + conv_id = f"conv_{conv_id}" + try: + if rec.username == "host": + path = Path(DATA_DIR).expanduser().resolve() / "conversations" / rec.workspace_id / f"{conv_id}.json" + else: + # 普通 Web 用户当前仍是单工作区布局;Docker 项目化阶段会改为项目目录。 + from . import state + workspace = state.user_manager.ensure_user_workspace(rec.username) + path = Path(workspace.data_dir).expanduser().resolve() / "conversations" / f"{conv_id}.json" + if not path.exists(): + return None + data = json.loads(path.read_text(encoding="utf-8")) + title = str(data.get("title") or "").strip() + return title or None + except Exception: + return None + + +def _workspace_label_for_task(rec: TaskRecord) -> Optional[str]: + try: + if rec.username == "host": + from modules.host_workspace_manager import load_host_workspace_catalog + catalog = load_host_workspace_catalog() + for item in catalog.get("workspaces") or []: + if item.get("workspace_id") == rec.workspace_id: + return str(item.get("label") or rec.workspace_id) + except Exception: + pass + return rec.workspace_id + + +def _task_public_payload(rec: TaskRecord, *, include_title: bool = True) -> Dict[str, Any]: + payload = { + "task_id": rec.task_id, + "username": rec.username, + "workspace_id": rec.workspace_id, + "workspace_label": _workspace_label_for_task(rec), + "status": rec.status, + "created_at": rec.created_at, + "updated_at": rec.updated_at, + "message": rec.message, + "conversation_id": rec.conversation_id, + "error": rec.error, + } + if include_title: + payload["conversation_title"] = _conversation_title_for_task(rec) + return payload + + @tasks_bp.route("/api/tasks", methods=["GET"]) @api_login_required def list_tasks_api(): username = get_current_username() - recs = task_manager.list_tasks(username) + workspace_id = (request.args.get("workspace_id") or "").strip() or None + status_filter = (request.args.get("status") or "").strip().lower() + recs = task_manager.list_tasks(username, workspace_id) + if status_filter: + if status_filter == "active": + active = {"pending", "running", "cancel_requested"} + recs = [rec for rec in recs if rec.status in active] + else: + wanted = {part.strip() for part in status_filter.split(",") if part.strip()} + recs = [rec for rec in recs if rec.status in wanted] return jsonify({ "success": True, "data": [ - { - "task_id": r.task_id, - "status": r.status, - "created_at": r.created_at, - "updated_at": r.updated_at, - "message": r.message, - "conversation_id": r.conversation_id, - "error": r.error, - } for r in sorted(recs, key=lambda x: x.created_at, reverse=True) + _task_public_payload(r) + for r in sorted(recs, key=lambda x: x.created_at, reverse=True) ] }) @@ -786,6 +859,7 @@ def create_task_api(): "success": True, "data": { "task_id": rec.task_id, + "workspace_id": rec.workspace_id, "status": rec.status, "created_at": rec.created_at, "conversation_id": rec.conversation_id, @@ -871,6 +945,7 @@ def get_task_api(task_id: str): "success": True, "data": { "task_id": rec.task_id, + "workspace_id": rec.workspace_id, "status": rec.status, "created_at": rec.created_at, "updated_at": rec.updated_at, diff --git a/static/src/App.vue b/static/src/App.vue index 413d13c..67378b8 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -62,6 +62,8 @@ v-if="!isMobileViewport" :icon-style="iconStyle" :format-time="formatTime" + :running-tasks="runningWorkspaceTasks" + :current-workspace-id="currentHostWorkspaceId" :display-mode="chatDisplayMode" :display-mode-disabled="displayModeSwitchDisabled" @toggle="toggleSidebar" @@ -70,6 +72,7 @@ @search-submit="handleSidebarSearchSubmit" @search-more="loadMoreSearchResults" @select="loadConversation" + @select-running-task="openRunningWorkspaceTask" @load-more="loadMoreConversations" @personal="openPersonalPage" @delete="deleteConversation" @@ -262,7 +265,7 @@ :input-is-multiline="inputIsMultiline" :input-is-focused="inputIsFocused" :is-connected="isConnected" - :streaming-message="composerBusy" + :streaming-message="composerStreamingForInput" :input-locked="displayLockEngaged" :uploading="uploading" :media-uploading="mediaUploading" @@ -668,6 +671,8 @@ class="mobile-overlay-content" :icon-style="iconStyle" :format-time="formatTime" + :running-tasks="runningWorkspaceTasks" + :current-workspace-id="currentHostWorkspaceId" :display-mode="chatDisplayMode" :display-mode-disabled="displayModeSwitchDisabled" :show-collapse-button="true" @@ -678,6 +683,7 @@ @search-submit="handleSidebarSearchSubmit" @search-more="loadMoreSearchResults" @select="handleMobileOverlaySelect($event)" + @select-running-task="openRunningWorkspaceTask" @load-more="loadMoreConversations" @personal="openPersonalPage" @delete="deleteConversation" diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index 0a1d71b..fb00092 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -64,7 +64,9 @@ export const computed = { 'searchOffset', 'searchTotal', 'conversationsOffset', - 'conversationsLimit' + 'conversationsLimit', + 'runningWorkspaceTasks', + 'acknowledgedCompletedTaskIds' ]), ...mapWritableState(useModelStore, ['currentModelKey']), ...mapState(useModelStore, ['models']), @@ -168,7 +170,21 @@ export const computed = { return !!this.policyUiBlocks.block_virtual_monitor; }, displayLockEngaged() { - return !!this.compressionInProgress || !!this.compressing; + return !!this.compressionInProgress || !!this.compressing || !!this.currentWorkspaceHasRunningTask; + }, + currentWorkspaceHasRunningTask() { + if (!this.versioningHostMode || !this.currentHostWorkspaceId) { + return false; + } + const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []; + return tasks.some((task: any) => { + const status = String(task?.status || ''); + return ( + task?.workspace_id === this.currentHostWorkspaceId && + task?.conversation_id !== this.currentConversationId && + ['pending', 'running', 'cancel_requested'].includes(status) + ); + }); }, streamingUi() { return this.streamingMessage || this.hasPendingToolActions(); @@ -184,6 +200,9 @@ export const computed = { this.compressing ); }, + composerStreamingForInput() { + return this.composerBusy && !this.displayLockEngaged; + }, composerHeroActive() { return (this.blankHeroActive || this.blankHeroExiting) && !this.composerInteractionActive; }, diff --git a/static/src/app/methods/conversation.ts b/static/src/app/methods/conversation.ts index 06e405a..18e84da 100644 --- a/static/src/app/methods/conversation.ts +++ b/static/src/app/methods/conversation.ts @@ -279,47 +279,19 @@ export const conversationMethods = { // 注意:加载已有对话时必须保留该对话自身的模型/模式,不能套用用户默认值。 - // 有任务或后台子智能体运行时,提示用户确认切换 + // 多工作区并行后,切换对话只切换视图,不再取消后台任务;停止当前轮询避免事件写入新对话界面。 try { const { useTaskStore } = await import('../../stores/task'); const taskStore = useTaskStore(); - const hasActiveTask = - taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent; - if (hasActiveTask) { - const waitingLabel = this.waitingForBackgroundCommand ? '后台指令' : '后台子智能体'; - const confirmed = await this.confirmAction({ - title: '切换对话', - message: this.waitingForSubAgent - ? `${waitingLabel}正在运行,切换对话后将不会自动接收完成提示。确定要切换吗?` - : '当前有任务正在执行,切换对话后任务会停止。确定要切换吗?', - confirmText: '切换', - cancelText: '取消' - }); - if (!confirmed) { - this.suppressTitleTyping = false; - this.titleReady = true; - return; + if (taskStore.hasActiveTask || this.taskInProgress) { + taskStore.clearTask(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); } - - if (taskStore.hasActiveTask) { - await taskStore.cancelTask(); - taskStore.clearTask(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - } - - if (this.waitingForSubAgent && !taskStore.hasActiveTask) { - this.waitingForSubAgent = false; - this.waitingForBackgroundCommand = false; - } - - this.streamingMessage = false; - this.taskInProgress = false; - this.stopRequested = false; } + this.clearLocalTaskUiState?.(`switch-conversation:${conversationId}`); } catch (error) { - console.error('[切换对话] 检查/停止任务失败:', error); + console.error('[切换对话] 停止本地轮询失败:', error); } await this.persistComposerDraftNow({ @@ -376,6 +348,22 @@ export const conversationMethods = { // 4. 立即加载历史和统计,确保列表切换后界面同步更新 await this.fetchAndDisplayHistory(); + await this.refreshRunningWorkspaceTasks?.(); + const visibleTask = + typeof this.getVisibleWorkspaceTaskForConversation === 'function' + ? this.getVisibleWorkspaceTaskForConversation(conversationId) + : null; + const visibleTaskStatus = String(visibleTask?.status || ''); + const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); + const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']); + if (visibleTask && activeStatuses.has(visibleTaskStatus)) { + // 切回正在运行的对话时,必须恢复该任务的 REST 轮询和输入区忙碌态; + // 否则只切换了历史视图,后续输出不会续接到当前对话,发送按钮也不会变成停止按钮。 + await this.restoreTaskState?.(); + } else if (visibleTask && terminalStatuses.has(visibleTaskStatus)) { + // 用户已点进完成后的“待查看”对话,清除列表里的完成标记。 + this.acknowledgeCompletedWorkspaceTask?.(visibleTask.task_id); + } this.fetchPermissionMode(); this.fetchExecutionMode(); await this.fetchVersioningStatus(conversationId, { silent: true }); @@ -497,54 +485,19 @@ export const conversationMethods = { console.warn('应用个性化默认设置失败:', error); } - // 检查是否有运行中的任务,如果有则提示用户 - let hasActiveTask = false; + // 创建新对话只切换视图,不再取消后台任务;停止当前轮询避免事件串写。 try { const { useTaskStore } = await import('../../stores/task'); const taskStore = useTaskStore(); - - if (taskStore.hasActiveTask || this.taskInProgress || this.waitingForSubAgent) { - hasActiveTask = true; - const waitingLabel = this.waitingForBackgroundCommand ? '后台指令' : '后台子智能体'; - - // 显示提示 - const confirmed = await this.confirmAction({ - title: '创建新对话', - message: this.waitingForSubAgent - ? `${waitingLabel}正在运行,创建新对话后将不会自动接收完成提示。确定要创建吗?` - : '当前有任务正在执行,创建新对话后任务会停止。确定要创建吗?', - confirmText: '创建', - cancelText: '取消' - }); - - if (!confirmed) { - // 用户取消创建 - return; + if (taskStore.hasActiveTask || this.taskInProgress) { + taskStore.clearTask(); + if (typeof this.clearProcessedEvents === 'function') { + this.clearProcessedEvents(); } - - // 用户确认,停止任务 - debugLog('[创建新对话] 用户确认,正在停止任务...'); - if (taskStore.hasActiveTask) { - await taskStore.cancelTask(); - taskStore.clearTask(); - if (typeof this.clearProcessedEvents === 'function') { - this.clearProcessedEvents(); - } - } - - // 重置任务相关状态 - this.streamingMessage = false; - this.taskInProgress = false; - this.stopRequested = false; - if (this.waitingForSubAgent && !taskStore.hasActiveTask) { - this.waitingForSubAgent = false; - this.waitingForBackgroundCommand = false; - } - - debugLog('[创建新对话] 任务已停止'); } + this.clearLocalTaskUiState?.('create-new-conversation'); } catch (error) { - console.error('[创建新对话] 检查/停止任务失败:', error); + console.error('[创建新对话] 停止本地轮询失败:', error); } try { @@ -615,15 +568,7 @@ export const conversationMethods = { conversationsLen: Array.isArray(this.conversations) ? this.conversations.length : 'n/a' }); - // 如果停止了任务,显示提示 - if (hasActiveTask) { - this.uiPushToast({ - title: '任务已停止', - message: '创建新对话后,之前的任务已停止', - type: 'info', - duration: 3000 - }); - } + await this.refreshRunningWorkspaceTasks?.(); } else { console.error('创建对话失败:', result.message); this.uiPushToast({ diff --git a/static/src/app/methods/message.ts b/static/src/app/methods/message.ts index b046e55..a14da15 100644 --- a/static/src/app/methods/message.ts +++ b/static/src/app/methods/message.ts @@ -565,6 +565,14 @@ export const messageMethods = { const hasMedia = (Array.isArray(this.selectedImages) && this.selectedImages.length > 0) || (Array.isArray(this.selectedVideos) && this.selectedVideos.length > 0); + if (this.currentWorkspaceHasRunningTask) { + this.uiPushToast({ + title: '当前工作区正在运行', + message: '请先在“运行中的任务”中查看或等待任务完成后再发送新消息。', + type: 'warning' + }); + return; + } if (this.composerBusy) { if (hasText) { if (Array.isArray(this.pendingUserQuestions) && this.pendingUserQuestions.length > 0) { @@ -784,6 +792,7 @@ export const messageMethods = { }); debugLog('[Message] 任务已创建,开始轮询'); + await this.refreshRunningWorkspaceTasks?.(); } catch (error) { console.error('[Message] 创建任务失败:', error); this.uiPushToast({ diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 312ae22..6977202 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -1,5 +1,6 @@ // @ts-nocheck import { debugLog } from './common'; +import { useTaskStore } from '../../stores/task'; const debugNotifyLog = (...args: any[]) => { void args; @@ -178,6 +179,57 @@ export const taskPollingMethods = { return true; }, + ensureRunningAssistantPlaceholder(runningTask: any = null, reason = 'restore-running-task') { + if (!Array.isArray(this.messages) || this.messages.length === 0) { + return false; + } + const last = this.messages[this.messages.length - 1]; + const lastActions = Array.isArray(last?.actions) ? last.actions : []; + const alreadyWaiting = + last?.role === 'assistant' && lastActions.length === 0 && !!last.awaitingFirstContent; + if (alreadyWaiting) { + this.taskInProgress = true; + this.streamingMessage = true; + this.stopRequested = false; + return false; + } + if (last?.role === 'assistant' && lastActions.length > 0) { + return false; + } + if (last?.role !== 'user') { + return false; + } + + const startedAt = + typeof runningTask?.created_at === 'number' + ? new Date(runningTask.created_at * 1000).toISOString() + : new Date().toISOString(); + last.metadata = { + ...(last.metadata || {}), + work_timer: last.metadata?.work_timer || { + status: 'working', + started_at: startedAt + } + }; + this.taskInProgress = true; + this.stopRequested = false; + this.streamingMessage = true; + this.chatStartAssistantMessage(); + if (typeof this.monitorShowPendingReply === 'function') { + this.monitorShowPendingReply(); + } + this.$forceUpdate(); + this.$nextTick(() => { + this.conditionalScrollToBottom?.(); + }); + debugLog('[TaskPolling] 已恢复等待首包占位', { + reason, + conversationId: this.currentConversationId, + taskId: runningTask?.task_id || null + }); + return true; + }, + stopWaitingTaskProbe() { if (this.waitingTaskProbeTimer) { debugNotifyLog('[DEBUG_NOTIFY][ui] stopWaitingTaskProbe'); @@ -287,7 +339,11 @@ export const taskPollingMethods = { }); const runningTask = tasks.find( (task: any) => - task?.status === 'running' && task?.conversation_id === this.currentConversationId + task?.status === 'running' && + task?.conversation_id === this.currentConversationId && + (!this.versioningHostMode || + !this.currentHostWorkspaceId || + task?.workspace_id === this.currentHostWorkspaceId) ); if (!runningTask?.task_id) { debugNotifyLog('[DEBUG_NOTIFY][ui] tryResumeWaitingNoticeTask:no-matched-task'); @@ -376,6 +432,7 @@ export const taskPollingMethods = { const eventType = event.type; const eventData = event.data || {}; const eventIdx = event.idx; + const taskStore = useTaskStore(); if (eventType === 'task_complete' || eventType === 'task_stopped' || eventType === 'error') { jsonDebug('task-event', { eventType, @@ -411,48 +468,10 @@ export const taskPollingMethods = { }); } - // 事件去重检查 - if (typeof eventIdx === 'number') { - if (!this._processedEventIndices) { - this._processedEventIndices = new Set(); - } - - if (this._processedEventIndices.has(eventIdx)) { - if ( - [ - 'ai_message_start', - 'thinking_start', - 'thinking_chunk', - 'thinking_end', - 'text_start', - 'text_chunk', - 'text_end' - ].includes(eventType) - ) { - restoreDebugLog('event:drop-duplicate', { - eventType, - eventIdx, - currentConversationId: this.currentConversationId - }); - } - debugLog(`[TaskPolling] 跳过重复事件 #${eventIdx}`); - return; - } - - this._processedEventIndices.add(eventIdx); - - // 限制 Set 大小(保留最近 1000 个) - if (this._processedEventIndices.size > 1000) { - const firstIdx = Math.min(...this._processedEventIndices); - this._processedEventIndices.delete(firstIdx); - } - } - // 检查事件的 conversation_id 是否匹配当前对话 // 如果不匹配,忽略该事件(避免切换对话后旧任务的事件显示到新对话中) const crossConversationAllowed = new Set([ 'shallow_compression', - 'conversation_resolved', 'compression_finished' ]); if ( @@ -491,6 +510,69 @@ export const taskPollingMethods = { return; } } + // 检查事件 task_id 是否仍是当前正在接管的任务,防止切换/新建对话后旧轮询的在途响应串写。 + if ( + !crossConversationAllowed.has(eventType) && + eventData.task_id && + (!taskStore.currentTaskId || eventData.task_id !== taskStore.currentTaskId) + ) { + restoreDebugLog('event:drop-task-mismatch', { + eventType, + eventIdx, + eventTaskId: eventData.task_id, + currentTaskId: taskStore.currentTaskId, + eventConversationId: eventData.conversation_id, + currentConversationId: this.currentConversationId + }); + debugLog( + `[TaskPolling] 忽略不匹配的任务事件 #${eventIdx}: ${eventType}, 事件任务=${eventData.task_id}, 当前任务=${taskStore.currentTaskId}` + ); + return; + } + + // 事件去重检查必须在 conversation/task 校验之后执行,避免其他对话同 idx 事件污染当前任务去重表。 + const dedupeKey = + eventData.task_id && typeof eventIdx === 'number' + ? `${eventData.task_id}:${eventIdx}` + : typeof eventIdx === 'number' + ? `idx:${eventIdx}` + : ''; + if (dedupeKey) { + if (!this._processedEventIndices) { + this._processedEventIndices = new Set(); + } + + if (this._processedEventIndices.has(dedupeKey)) { + if ( + [ + 'ai_message_start', + 'thinking_start', + 'thinking_chunk', + 'thinking_end', + 'text_start', + 'text_chunk', + 'text_end' + ].includes(eventType) + ) { + restoreDebugLog('event:drop-duplicate', { + eventType, + eventIdx, + dedupeKey, + currentConversationId: this.currentConversationId + }); + } + debugLog(`[TaskPolling] 跳过重复事件 ${dedupeKey}`); + return; + } + + this._processedEventIndices.add(dedupeKey); + + // 限制 Set 大小(保留最近 1000 个) + if (this._processedEventIndices.size > 1000) { + const firstValue = this._processedEventIndices.values().next().value; + this._processedEventIndices.delete(firstValue); + } + } debugLog(`[TaskPolling] 处理事件 #${eventIdx}: ${eventType}`, eventData); @@ -1669,6 +1751,10 @@ export const taskPollingMethods = { }, 500); } this.scheduleTodoListRefresh(100); + if (data?.conversation_id && data.conversation_id === this.currentConversationId && data?.task_id) { + this.acknowledgeCompletedWorkspaceTask?.(data.task_id); + } + setTimeout(() => this.refreshRunningWorkspaceTasks?.(), 0); // 标题生成可能晚于主任务完成;主轮询停止后主动短轮询当前会话标题,避免必须刷新页面。 this.scheduleGeneratedTitleRefresh('task_complete', { conversationId: data?.conversation_id || this.currentConversationId, @@ -1712,6 +1798,7 @@ export const taskPollingMethods = { this.clearPendingTools('task_stopped'); } this.scheduleTodoListRefresh(100); + setTimeout(() => this.refreshRunningWorkspaceTasks?.(), 0); this.$forceUpdate(); this.clearTaskState(); @@ -1844,7 +1931,12 @@ export const taskPollingMethods = { ); } this.taskInProgress = true; - this.streamingMessage = false; + // 恢复“已发送但尚未收到任何回复”的运行中对话时,前端会先补一个 + // awaitingFirstContent assistant 占位。重放 user_message 不能把这个等待态清掉, + // 否则输入区会丢失停止按钮。 + this.streamingMessage = isEmptyAssistantPlaceholderMessage(this.messages?.[this.messages.length - 1]) + ? true + : false; this.stopRequested = false; } else { const source = resolveUserMessageSource(data); @@ -2203,6 +2295,21 @@ export const taskPollingMethods = { await this.restoreSubAgentWaitingState(); return; } + if ( + this.versioningHostMode && + this.currentHostWorkspaceId && + runningTask?.workspace_id && + runningTask.workspace_id !== this.currentHostWorkspaceId + ) { + restoreDebugLog('restore:skip-workspace-mismatch', { + taskId: runningTask?.task_id, + taskWorkspaceId: runningTask?.workspace_id, + currentHostWorkspaceId: this.currentHostWorkspaceId + }); + taskStore.clearTask(); + await this.restoreSubAgentWaitingState(); + return; + } debugLog('[TaskPolling] 发现运行中的任务,开始恢复状态', { taskId: runningTask?.task_id, @@ -2302,9 +2409,41 @@ export const taskPollingMethods = { let inThinking = false; let inText = false; let hasTextChunkEvent = false; + let hasAssistantResponseEvent = false; + let hasAssistantContentEvent = false; for (let i = 0; i < allEvents.length; i++) { const event = allEvents[i]; + const eventType = String(event?.type || ''); + if ( + [ + 'ai_message_start', + 'thinking_start', + 'thinking_chunk', + 'thinking_end', + 'text_start', + 'text_chunk', + 'text_end', + 'tool_preparing', + 'tool_start', + 'tool_update', + 'tool_complete' + ].includes(eventType) + ) { + hasAssistantResponseEvent = true; + } + if ( + [ + 'thinking_chunk', + 'text_chunk', + 'tool_preparing', + 'tool_start', + 'tool_update', + 'tool_complete' + ].includes(eventType) + ) { + hasAssistantContentEvent = true; + } if (event.type === 'thinking_start') { inThinking = true; } else if (event.type === 'thinking_end') { @@ -2345,6 +2484,8 @@ export const taskPollingMethods = { isAssistantMessage, historyActionsCount, eventCount, + hasAssistantResponseEvent, + hasAssistantContentEvent, historyIncomplete, inText, hasTextChunkEvent, @@ -2370,6 +2511,9 @@ export const taskPollingMethods = { this.streamingMessage = true; this.taskInProgress = true; + if (!hasAssistantContentEvent) { + this.ensureRunningAssistantPlaceholder?.(runningTask, 'restore:no-visible-content-yet'); + } this.$forceUpdate(); // 重置偏移量为 0,从头获取所有事件来重建 assistant 消息 diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index 58b782a..fa21e8d 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -154,6 +154,45 @@ function parseSystemNoticeLabel(rawContent: any): string | null { } export const uiMethods = { + clearLocalTaskUiState(reason = 'safe-navigation') { + // 只清理当前浏览器视图里的运行态/工具态,不取消后端任务。 + // 用于切换对话/工作区后,避免旧任务残留让新视图的发送按钮显示为“停止”。 + try { + if (typeof this.stopWaitingTaskProbe === 'function') { + this.stopWaitingTaskProbe(); + } + } catch (_) {} + this.streamingMessage = false; + this.taskInProgress = false; + this.stopRequested = false; + this.waitingForSubAgent = false; + this.waitingForBackgroundCommand = false; + this.dropToolEvents = false; + this.runtimeQueuedMessages = []; + this.runtimeGuidanceFallbackQueue = []; + this.runtimeQueueAutoSendInProgress = false; + this.runtimeQueueSyncLockKey = ''; + this.runtimeQueueSyncLockUntil = 0; + if (typeof this.clearRuntimeQueueSuppressionState === 'function') { + this.clearRuntimeQueueSuppressionState(); + } else { + this.runtimeQueueSuppressedMessageIds = new Set(); + this.runtimeGuidanceSuppressedTextCounts = {}; + } + if (typeof this.clearPendingTools === 'function') { + this.clearPendingTools(reason); + } else { + this.preparingTools?.clear?.(); + this.activeTools?.clear?.(); + this.toolActionIndex?.clear?.(); + this.toolStacks?.clear?.(); + } + if (typeof this.chatClearThinkingLocks === 'function') { + this.chatClearThinkingLocks(); + } + this.$forceUpdate?.(); + }, + ensureScrollListener() { if (this._scrollListenerReady) { return; @@ -731,18 +770,214 @@ export const uiMethods = { workspace_id: String(item.workspace_id || ''), label: String(item.label || item.workspace_id || '未命名工作区'), path: String(item.path || ''), - is_current: !!item.is_current + is_current: !!item.is_current, + running_task_count: Number(item.running_task_count || 0) })); this.currentHostWorkspaceId = String(data.current_workspace_id || ''); this.defaultHostWorkspaceId = String(data.default_workspace_id || ''); + await this.refreshRunningWorkspaceTasks(); } catch (error) { console.warn('加载宿主机工作区失败:', error); this.hostWorkspaces = []; this.currentHostWorkspaceId = ''; this.defaultHostWorkspaceId = ''; + this.runningWorkspaceTasks = []; } }, + async refreshRunningWorkspaceTasks() { + if (!this.versioningHostMode) { + this.runningWorkspaceTasks = []; + if (this.runningWorkspaceTasksRefreshTimer) { + clearTimeout(this.runningWorkspaceTasksRefreshTimer); + this.runningWorkspaceTasksRefreshTimer = null; + } + return; + } + try { + const resp = await fetch('/api/tasks'); + const payload = await resp.json().catch(() => ({})); + if (!resp.ok || !payload?.success) { + throw new Error(payload?.error || '获取运行任务失败'); + } + const tasks = Array.isArray(payload.data) ? payload.data : []; + const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); + const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']); + const trackedTaskIds = this.getStoredWorkspaceTaskIdSet?.('tracked') || new Set(); + const acknowledged = new Set( + Array.isArray(this.acknowledgedCompletedTaskIds) ? this.acknowledgedCompletedTaskIds : [] + ); + const storedAcknowledged = this.getStoredWorkspaceTaskIdSet?.('acknowledged') || new Set(); + storedAcknowledged.forEach((id: string) => acknowledged.add(id)); + this.acknowledgedCompletedTaskIds = Array.from(acknowledged); + const previousVisibleIds = new Set( + (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []) + .map((task: any) => String(task?.task_id || '')) + .filter(Boolean) + ); + const visibleTasks = tasks.filter((task: any) => { + const taskId = String(task?.task_id || ''); + const status = String(task?.status || ''); + const conversationId = String(task?.conversation_id || ''); + if (!taskId) return false; + if (activeStatuses.has(status)) { + trackedTaskIds.add(taskId); + return true; + } + // 如果完成的是当前正在查看的对话,用户已经“看到了”,不要再进入待查看/显示勾。 + if (terminalStatuses.has(status) && conversationId === this.currentConversationId) { + if (!acknowledged.has(taskId)) { + acknowledged.add(taskId); + this.acknowledgedCompletedTaskIds = [ + ...(Array.isArray(this.acknowledgedCompletedTaskIds) + ? this.acknowledgedCompletedTaskIds + : []), + taskId + ]; + } + trackedTaskIds.delete(taskId); + return false; + } + // 刚完成的任务保留为“待查看”状态,直到用户点击进入该对话。 + return ( + terminalStatuses.has(status) && + (previousVisibleIds.has(taskId) || trackedTaskIds.has(taskId)) && + !acknowledged.has(taskId) + ); + }); + this.runningWorkspaceTasks = visibleTasks; + visibleTasks.forEach((task: any) => { + const taskId = String(task?.task_id || ''); + if (taskId) trackedTaskIds.add(taskId); + }); + acknowledged.forEach((id: string) => trackedTaskIds.delete(id)); + this.setStoredWorkspaceTaskIdSet?.('tracked', trackedTaskIds); + this.setStoredWorkspaceTaskIdSet?.('acknowledged', acknowledged); + const hasActiveVisibleTask = visibleTasks.some((task: any) => + activeStatuses.has(String(task?.status || '')) + ); + if (this.runningWorkspaceTasksRefreshTimer) { + clearTimeout(this.runningWorkspaceTasksRefreshTimer); + this.runningWorkspaceTasksRefreshTimer = null; + } + // 如果用户已经切到其他对话/工作区,当前运行任务没有本地轮询事件可触发列表更新。 + // 这里轻量轮询任务列表,使 loader 能在后台任务完成后自动切成“待查看”的勾。 + if (hasActiveVisibleTask) { + this.runningWorkspaceTasksRefreshTimer = window.setTimeout(() => { + this.runningWorkspaceTasksRefreshTimer = null; + void this.refreshRunningWorkspaceTasks?.(); + }, 1500); + } + if (Array.isArray(this.hostWorkspaces)) { + const counts = visibleTasks.reduce((acc: Record, task: any) => { + const ws = String(task?.workspace_id || ''); + const status = String(task?.status || ''); + if (ws && activeStatuses.has(status)) acc[ws] = (acc[ws] || 0) + 1; + return acc; + }, {}); + this.hostWorkspaces = this.hostWorkspaces.map((item: any) => ({ + ...item, + running_task_count: Number(counts[item.workspace_id] || 0) + })); + } + } catch (error) { + console.warn('刷新运行中任务失败:', error); + this.runningWorkspaceTasks = []; + if (this.runningWorkspaceTasksRefreshTimer) { + clearTimeout(this.runningWorkspaceTasksRefreshTimer); + this.runningWorkspaceTasksRefreshTimer = null; + } + } + }, + + getVisibleWorkspaceTaskForConversation(conversationId) { + const id = String(conversationId || '').trim(); + if (!id) return null; + const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []; + return ( + tasks.find((task: any) => String(task?.conversation_id || '') === id) || null + ); + }, + + getStoredWorkspaceTaskIdSet(kind = 'tracked') { + if (typeof window === 'undefined') return new Set(); + const key = + kind === 'acknowledged' + ? 'agents.hostWorkspaceTasks.acknowledged' + : 'agents.hostWorkspaceTasks.tracked'; + try { + const raw = window.localStorage?.getItem(key); + const values = JSON.parse(raw || '[]'); + return new Set( + (Array.isArray(values) ? values : []) + .map((item: any) => String(item || '').trim()) + .filter(Boolean) + ); + } catch { + return new Set(); + } + }, + + setStoredWorkspaceTaskIdSet(kind = 'tracked', values) { + if (typeof window === 'undefined') return; + const key = + kind === 'acknowledged' + ? 'agents.hostWorkspaceTasks.acknowledged' + : 'agents.hostWorkspaceTasks.tracked'; + try { + window.localStorage?.setItem( + key, + JSON.stringify(Array.from(values || []).map((item: any) => String(item))) + ); + } catch { + // ignore + } + }, + + acknowledgeCompletedWorkspaceTask(taskId) { + const id = String(taskId || '').trim(); + if (!id) return; + const current = Array.isArray(this.acknowledgedCompletedTaskIds) + ? this.acknowledgedCompletedTaskIds + : []; + if (!current.includes(id)) { + this.acknowledgedCompletedTaskIds = [...current, id]; + } + const acknowledged = this.getStoredWorkspaceTaskIdSet?.('acknowledged') || new Set(); + acknowledged.add(id); + this.setStoredWorkspaceTaskIdSet?.('acknowledged', acknowledged); + const tracked = this.getStoredWorkspaceTaskIdSet?.('tracked') || new Set(); + tracked.delete(id); + this.setStoredWorkspaceTaskIdSet?.('tracked', tracked); + this.runningWorkspaceTasks = (Array.isArray(this.runningWorkspaceTasks) + ? this.runningWorkspaceTasks + : [] + ).filter((item: any) => String(item?.task_id || '') !== id); + }, + + async openRunningWorkspaceTask(task) { + const workspaceId = String(task?.workspace_id || '').trim(); + const conversationId = String(task?.conversation_id || '').trim(); + if (!workspaceId || !conversationId) { + return; + } + if (this.versioningHostMode && workspaceId !== this.currentHostWorkspaceId) { + await this.handleHostWorkspaceSwitch(workspaceId); + } + if (this.currentConversationId === conversationId) { + return; + } + await this.loadConversation(conversationId, { force: true }); + const taskId = String(task?.task_id || ''); + if (taskId && !['pending', 'running', 'cancel_requested'].includes(String(task?.status || ''))) { + this.acknowledgeCompletedWorkspaceTask(taskId); + return; + } + // 复用刷新页面时的运行任务恢复逻辑:它会等待历史加载、按事件重建未完成的 assistant + // 消息,并重新注册思考/工具块,避免重复加载和块分裂。 + await this.restoreTaskState(); + }, + async handleHostWorkspaceSwitch(workspaceId) { const targetId = String(workspaceId || '').trim(); if (!targetId || this.hostWorkspaceSwitching) { @@ -779,14 +1014,37 @@ export const uiMethods = { if (data.default_workspace_id) { this.defaultHostWorkspaceId = String(data.default_workspace_id); } + this.messages = []; + this.currentConversationId = null; + this.currentConversationTitle = '新对话'; + this.titleReady = true; + this.suppressTitleTyping = false; + try { + const { useTaskStore } = await import('../../stores/task'); + const taskStore = useTaskStore(); + taskStore.clearTask(); + } catch (_) {} + this.clearLocalTaskUiState?.('switch-host-workspace'); + this.conversationsOffset = 0; + await this.fetchHostWorkspaces(); + const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); + const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []) + .filter((task: any) => String(task?.workspace_id || '') === this.currentHostWorkspaceId) + .find((task: any) => activeStatuses.has(String(task?.status || '')) && task?.conversation_id); + if (activeTask?.conversation_id) { + await this.loadConversation(String(activeTask.conversation_id), { force: true }); + this.conversationsOffset = 0; + await this.loadConversationsList(); + } else { + this.startTitleTyping('新对话', { animate: false }); + history.replaceState({}, '', '/new'); + await this.loadConversationsList(); + } this.uiPushToast({ title: '工作区已切换', - message: '正在刷新页面…', + message: data.project_path || targetId, type: 'success' }); - window.setTimeout(() => { - window.location.reload(); - }, 260); } catch (error) { const message = error instanceof Error ? error.message : String(error || '切换失败'); this.uiPushToast({ @@ -2320,7 +2578,12 @@ export const uiMethods = { const payload = await resp.json(); const tasks = Array.isArray(payload?.data) ? payload.data : []; const runningTask = tasks.find( - (task: any) => task?.status === 'running' && task?.conversation_id + (task: any) => + task?.status === 'running' && + task?.conversation_id && + (!this.versioningHostMode || + !this.currentHostWorkspaceId || + task?.workspace_id === this.currentHostWorkspaceId) ); return runningTask?.conversation_id || null; } catch (error) { diff --git a/static/src/app/state.ts b/static/src/app/state.ts index 46de7a7..d6e2714 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -25,6 +25,8 @@ export function dataState() { taskInProgress: false, // 等待后台通知期间,用于发现并接管新建任务的轮询定时器 waitingTaskProbeTimer: null, + // 宿主机多工作区任务列表后台刷新定时器(用于当前未查看运行对话时同步完成态) + runningWorkspaceTasksRefreshTimer: null, // 运行期消息堆积(提前发送 / 引导对话) runtimeQueuedMessages: [], runtimeGuidanceFallbackQueue: [], diff --git a/static/src/components/panels/LeftPanel.vue b/static/src/components/panels/LeftPanel.vue index 170b4ec..fd8000a 100644 --- a/static/src/components/panels/LeftPanel.vue +++ b/static/src/components/panels/LeftPanel.vue @@ -232,6 +232,12 @@ > 当前 + + 运行中 +
@@ -292,6 +298,7 @@ const props = defineProps<{ label: string; path?: string; is_current?: boolean; + running_task_count?: number; }>; hostWorkspaceId?: string; hostWorkspaceDefaultId?: string; diff --git a/static/src/components/sidebar/ConversationSidebar.vue b/static/src/components/sidebar/ConversationSidebar.vue index a8d167a..77500cd 100644 --- a/static/src/components/sidebar/ConversationSidebar.vue +++ b/static/src/components/sidebar/ConversationSidebar.vue @@ -104,6 +104,30 @@
+
+
运行中的任务
+ +
+ +
@@ -252,12 +291,16 @@ const props = withDefaults( collapseButtonVariant?: 'toggle' | 'close'; displayMode?: 'chat' | 'monitor'; displayModeDisabled?: boolean; + runningTasks?: any[]; + currentWorkspaceId?: string; }>(), { showCollapseButton: true, collapseButtonVariant: 'toggle', displayMode: 'chat', - displayModeDisabled: false + displayModeDisabled: false, + runningTasks: () => [], + currentWorkspaceId: '' } ); @@ -267,6 +310,7 @@ defineEmits<{ (event: 'search', value: string): void; (event: 'search-submit', value: string): void; (event: 'select', id: string): void; + (event: 'select-running-task', task: any): void; (event: 'load-more'): void; (event: 'search-more'): void; (event: 'personal'): void; @@ -331,4 +375,42 @@ const displayConversations = computed(() => const loading = computed(() => searchActive.value ? searchInProgress.value : conversationsLoading.value ); +const runningTaskItems = computed(() => + Array.isArray(props.runningTasks) + ? props.runningTasks.filter( + (task) => + task && + task.task_id && + task.conversation_id && + task.conversation_id !== currentConversationId.value && + String(task.workspace_id || '') !== String(props.currentWorkspaceId || '') + ) + : [] +); +const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); +const terminalStatuses = new Set(['succeeded', 'failed', 'canceled']); +const isTaskActive = (task: any) => activeStatuses.has(String(task?.status || '')); +const isTaskCompleted = (task: any) => terminalStatuses.has(String(task?.status || '')); +const activeConversationIds = computed( + () => + new Set( + (Array.isArray(props.runningTasks) ? props.runningTasks : []) + .filter((task) => isTaskActive(task)) + .map((task) => String(task?.conversation_id || '')) + .filter(Boolean) + ) +); +const completedConversationIds = computed( + () => + new Set( + (Array.isArray(props.runningTasks) ? props.runningTasks : []) + .filter((task) => isTaskCompleted(task)) + .map((task) => String(task?.conversation_id || '')) + .filter(Boolean) + ) +); +const isConversationActive = (conversationId: string) => + activeConversationIds.value.has(String(conversationId || '')); +const isConversationCompleted = (conversationId: string) => + completedConversationIds.value.has(String(conversationId || '')); diff --git a/static/src/stores/conversation.ts b/static/src/stores/conversation.ts index 3a5b5d0..c88451f 100644 --- a/static/src/stores/conversation.ts +++ b/static/src/stores/conversation.ts @@ -27,6 +27,8 @@ interface ConversationState { searchTotal: number; conversationsOffset: number; conversationsLimit: number; + runningWorkspaceTasks: any[]; + acknowledgedCompletedTaskIds: string[]; } export const useConversationStore = defineStore('conversation', { @@ -48,7 +50,9 @@ export const useConversationStore = defineStore('conversation', { searchOffset: 0, searchTotal: 0, conversationsOffset: 0, - conversationsLimit: 20 + conversationsLimit: 20, + runningWorkspaceTasks: [], + acknowledgedCompletedTaskIds: [] }), actions: { resetConversations() { @@ -64,6 +68,8 @@ export const useConversationStore = defineStore('conversation', { this.hasMoreConversations = false; this.loadingMoreConversations = false; this.conversationsOffset = 0; + this.runningWorkspaceTasks = []; + this.acknowledgedCompletedTaskIds = []; }, cancelSearchTimer() { if (this.searchTimer) { diff --git a/static/src/stores/task.ts b/static/src/stores/task.ts index b2b21a1..23ebfe3 100644 --- a/static/src/stores/task.ts +++ b/static/src/stores/task.ts @@ -170,6 +170,17 @@ export const useTaskStore = defineStore('task', { } const data = result.data; + // 如果用户已切换/新建对话并清除了当前轮询,忽略这次已在路上的旧响应。 + // 否则旧任务事件可能被写入当前新对话视图。 + if (this.currentTaskId !== taskId) { + taskPollDiag('task-poll-stale-response-ignored', { + requestId, + responseTaskId: data?.task_id || taskId, + activeTaskId: this.currentTaskId, + from: fromOffset + }); + return; + } const eventsCount = Array.isArray(data?.events) ? data.events.length : 0; jsonDebug('taskStore.poll:response', { taskId, @@ -225,6 +236,14 @@ export const useTaskStore = defineStore('task', { debugLog(`[Task] 收到 ${data.events.length} 个新事件`); for (const event of data.events) { + if (this.currentTaskId !== taskId) { + taskPollDiag('task-poll-stale-event-loop-abort', { + requestId, + responseTaskId: data?.task_id || taskId, + activeTaskId: this.currentTaskId + }); + return; + } if ( event?.type === 'task_complete' || event?.type === 'task_stopped' || @@ -528,10 +547,11 @@ export const useTaskStore = defineStore('task', { throw new Error(result.error || '获取任务列表失败'); } - // 查找运行中的任务 + // 查找当前对话的活动任务 + const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); const runningTask = result.data.find( (task: any) => - task.status === 'running' && + activeStatuses.has(String(task.status || '')) && (!conversationId || task.conversation_id === conversationId) ); diff --git a/static/src/styles/components/panels/_left-panel.scss b/static/src/styles/components/panels/_left-panel.scss index 15c89d4..9643561 100644 --- a/static/src/styles/components/panels/_left-panel.scss +++ b/static/src/styles/components/panels/_left-panel.scss @@ -683,6 +683,12 @@ border-color: rgba(47, 111, 78, 0.35); background: rgba(118, 176, 134, 0.18); } + + &.running { + color: var(--claude-deep-strong); + border-color: color-mix(in srgb, var(--claude-accent) 34%, transparent); + background: color-mix(in srgb, var(--claude-highlight) 78%, transparent); + } } [data-theme='dark'] .host-workspace-header-box { @@ -714,6 +720,12 @@ border-color: rgba(142, 219, 180, 0.34); background: rgba(16, 185, 129, 0.2); } + + &.running { + color: #f7c56f; + border-color: rgba(247, 197, 111, 0.35); + background: rgba(247, 197, 111, 0.16); + } } .host-workspace-path { diff --git a/static/src/styles/components/sidebar/_conversation.scss b/static/src/styles/components/sidebar/_conversation.scss index 27b642e..cff6312 100644 --- a/static/src/styles/components/sidebar/_conversation.scss +++ b/static/src/styles/components/sidebar/_conversation.scss @@ -222,6 +222,81 @@ position: relative; } +.conversation-sidebar .running-task-section { + margin: 0 8px 12px; + padding: 6px 0 8px; + border-bottom: 1px solid color-mix(in srgb, var(--claude-border) 72%, transparent); +} + +.conversation-sidebar .running-task-section-title { + margin: 0 8px 4px; + font-size: 12px; + font-weight: 700; + color: var(--claude-text-secondary); + letter-spacing: 0.01em; +} + +.conversation-sidebar .running-task-item { + position: relative; + display: grid; + width: 100%; + grid-template-columns: minmax(0, 1fr); + gap: 3px; + margin: 1px 0; + padding: 8px 10px 8px 12px; + border: 0; + border-radius: 12px; + background: transparent; + color: var(--claude-text); + text-align: left; + cursor: pointer; + transition: + background 140ms ease, + color 140ms ease; +} + +.conversation-sidebar .running-task-item:hover, +.conversation-sidebar .running-task-item.active { + background: var(--theme-tab-active, rgba(0, 0, 0, 0.055)); +} + +.conversation-sidebar .running-task-workspace { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 11px; + font-weight: 600; + color: var(--claude-text-secondary); +} + +.conversation-sidebar .running-task-title { + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; + font-size: 14px; + line-height: 1.25; + letter-spacing: -0.01em; + color: var(--claude-text); +} + +.conversation-sidebar .running-task-main { + display: grid; + grid-template-columns: minmax(0, 1fr) 18px; + align-items: center; + gap: 8px; +} + +.conversation-sidebar .running-task-state { + justify-self: center; +} + +[data-theme='dark'] .conversation-sidebar .running-task-item:hover, +[data-theme='dark'] .conversation-sidebar .running-task-item.active, +body[data-theme='dark'] .conversation-sidebar .running-task-item:hover, +body[data-theme='dark'] .conversation-sidebar .running-task-item.active { + background: #222222; +} + .conversation-sidebar .conversation-item { position: relative; grid-template-columns: minmax(0, 1fr) 40px; @@ -365,13 +440,43 @@ body[data-theme='dark'] .conversation-sidebar .conversation-item.active:focus-wi .conversation-sidebar .conversation-action-wrap { position: relative; - width: 34px; + width: 40px; height: 34px; display: grid; place-items: center; justify-self: start; } +.conversation-sidebar .conversation-running-loader { + width: 16px; + height: 16px; + border: 2px solid var(--claude-text-secondary); + border-bottom-color: transparent; + border-radius: 50%; + display: inline-block; + box-sizing: border-box; + animation: conversation-running-rotation 1s linear infinite; +} + +.conversation-sidebar .conversation-complete-check { + width: 18px; + height: 18px; + display: inline-block; + background: var(--claude-text-secondary); + mask: url('/static/icons/check.svg') center / contain no-repeat; + -webkit-mask: url('/static/icons/check.svg') center / contain no-repeat; +} + +@keyframes conversation-running-rotation { + 0% { + transform: rotate(0deg); + } + + 100% { + transform: rotate(360deg); + } +} + .conversation-sidebar .conversation-more-btn { width: 30px; height: 30px;