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;