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.
This commit is contained in:
JOJO 2026-05-29 17:54:11 +08:00
parent 672110c213
commit 03cb39924a
18 changed files with 1011 additions and 264 deletions

View File

@ -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

View File

@ -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)

View File

@ -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:

View File

@ -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

View File

@ -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,

View File

@ -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"

View File

@ -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;
},

View File

@ -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({

View File

@ -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({

View File

@ -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 消息

View File

@ -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<string, number>, 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) {

View File

@ -25,6 +25,8 @@ export function dataState() {
taskInProgress: false,
// 等待后台通知期间,用于发现并接管新建任务的轮询定时器
waitingTaskProbeTimer: null,
// 宿主机多工作区任务列表后台刷新定时器(用于当前未查看运行对话时同步完成态)
runningWorkspaceTasksRefreshTimer: null,
// 运行期消息堆积(提前发送 / 引导对话)
runtimeQueuedMessages: [],
runtimeGuidanceFallbackQueue: [],

View File

@ -232,6 +232,12 @@
>
当前
</span>
<span
v-if="Number(item.running_task_count || 0) > 0"
class="host-workspace-badge running"
>
运行中
</span>
</span>
</div>
<div class="host-workspace-path">
@ -292,6 +298,7 @@ const props = defineProps<{
label: string;
path?: string;
is_current?: boolean;
running_task_count?: number;
}>;
hostWorkspaceId?: string;
hostWorkspaceDefaultId?: string;

View File

@ -104,6 +104,30 @@
</div>
<div class="conversation-list">
<div v-if="runningTaskItems.length && !searchActive" class="running-task-section">
<div class="running-task-section-title">运行中的任务</div>
<button
v-for="task in runningTaskItems"
:key="task.task_id"
type="button"
class="running-task-item"
:class="{ active: task.conversation_id === currentConversationId }"
@click="$emit('select-running-task', task)"
>
<span class="running-task-workspace">{{ task.workspace_label || task.workspace_id }}</span>
<span class="running-task-main">
<span class="running-task-title">{{
task.conversation_title || task.message || '运行中的对话'
}}</span>
<span
v-if="isTaskActive(task)"
class="conversation-running-loader running-task-state"
aria-label="运行中"
></span>
<span v-else class="conversation-complete-check running-task-state" aria-label="已完成"></span>
</span>
</button>
</div>
<div
v-if="loading && !displayConversations.length && !searchActive"
class="loading-conversations"
@ -126,6 +150,7 @@
class="conversation-item"
:class="{
active: conv.id === currentConversationId,
running: isConversationActive(conv.id) || isConversationCompleted(conv.id),
'insert-from-left': conversationInsertAnimations[conv.id] === 'create',
'insert-from-under': conversationInsertAnimations[conv.id] === 'duplicate',
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource'
@ -141,7 +166,20 @@
:class="{ 'menu-open': openActionMenuId === conv.id }"
@click.stop
>
<span
v-if="isConversationActive(conv.id)"
class="conversation-running-loader"
aria-label="运行中"
title="运行中"
></span>
<span
v-else-if="isConversationCompleted(conv.id)"
class="conversation-complete-check"
aria-label="已完成"
title="已完成"
></span>
<button
v-else
type="button"
class="conversation-more-btn"
title="更多操作"
@ -152,6 +190,7 @@
<span aria-hidden="true"></span>
</button>
<div
v-if="!isConversationActive(conv.id) && !isConversationCompleted(conv.id)"
class="conversation-actions-menu"
:class="{ open: openActionMenuId === conv.id }"
>
@ -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 || ''));
</script>

View File

@ -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) {

View File

@ -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)
);

View File

@ -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 {

View File

@ -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;