Compare commits

...

3 Commits

Author SHA1 Message Date
b1cf4b9ff2 fix(frontend): restore conversation delete animation
Use a two-phase delete flow for conversation list items.

Instead of immediately removing the deleted conversation from the array and relying only on transition-group leave hooks, mark the target conversation as pending deletion first so it can play the left-slide animation, then remove it after the animation completes.

Also keep the list container mounted during delete mode so empty/loading branches do not interrupt the exit transition.

Validation: npm run build --silent 2>&1 | tail -n 20
2026-05-29 19:57:23 +08:00
08d9812f45 feat(docker): support isolated projects in web mode
Implement Docker Web project management on top of workspace_id while keeping host mode workspace behavior intact.

Backend changes:

- Move Docker user files to users/<user>/projects/<project_id>/{project,data,logs} and shared user state to users/<user>/personal.

- Add automatic legacy migration from old project/data/logs/agentskills layouts and from the temporary project/shared layout.

- Generate project IDs server-side from the project name, reserve internal IDs, and hide project paths from the UI.

- Add project rename/delete APIs; deleting a Docker project removes its project directory and releases terminal/container state.

- Add host workspace rename/delete APIs; deleting a host workspace only removes catalog entries and keeps files on disk.

- Scope Docker containers, terminals, uploads, conversations, storage, and running task metadata by workspace_id.

- Store private skills under users/<user>/personal/agentskills and sync them into each project workspace.

- Inject the current project/workspace name through prompts/workspace_system.txt.

Frontend changes:

- Replace Docker file-tree area with project management UX and reuse host multi-workspace running-task behavior.

- Add a management dialog for creating, renaming, and deleting projects/workspaces.

- Show project labels instead of internal IDs in toasts and project lists.

- Clear stale conversation history during project/workspace switching and show loading until the new list is ready.

- Keep running-task loader/check behavior consistent across host workspaces and Docker projects.

Validation:

- python3 -m py_compile modules/user_manager.py modules/host_workspace_manager.py modules/skills_manager.py core/main_terminal_parts/tools_execution.py server/auth.py server/context.py server/status.py server/conversation.py server/tasks.py utils/context_manager.py

- python3 -m unittest test.test_server_refactor_smoke

- npm run build --silent 2>&1 | tail -n 20
2026-05-29 19:49:48 +08:00
03cb39924a 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.
2026-05-29 17:54:11 +08:00
27 changed files with 2560 additions and 352 deletions

View File

@ -493,14 +493,10 @@ class MainTerminalToolsExecutionMixin:
if self._is_host_mode():
return Path(AGENT_SKILLS_DIR).expanduser().resolve()
data_dir = Path(self.data_dir).expanduser().resolve()
# 普通 Docker 用户: users/<username>/data -> users/<username>/agentskills
# API 工作区用户: users_api/<username>/workspaces/<ws>/data -> users_api/<username>/agentskills
if data_dir.name == "data" and data_dir.parent.parent.name == "workspaces":
user_root = data_dir.parent.parent.parent
else:
user_root = data_dir.parent
return (user_root / "agentskills").resolve()
inferred = infer_private_skills_dir(self.data_dir)
if inferred:
return inferred
return (Path(self.data_dir).expanduser().resolve().parent / "agentskills").resolve()
def _handle_create_skill_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
source = self._resolve_create_skill_source_dir(arguments.get("source_dir"))

View File

@ -240,7 +240,7 @@ def create_host_workspace(
workspace = {
"workspace_id": workspace_id,
"label": clean_label or workspace_id,
"label": clean_label or normalized_path.name or workspace_id,
"path": target_path_str,
}
raw_workspaces.append(workspace)
@ -259,6 +259,85 @@ def create_host_workspace(
}
def rename_host_workspace(
workspace_id: str,
label: str,
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
ws_id = str(workspace_id or "").strip()
clean_label = str(label or "").strip()
if not ws_id:
raise ValueError("缺少 workspace_id")
if not clean_label:
raise ValueError("工作区名称不能为空")
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=True)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
updated = None
for idx, item in enumerate(raw_workspaces):
normalized = _normalize_entry(item, idx)
if not normalized or normalized.get("workspace_id") != ws_id:
continue
item["workspace_id"] = ws_id
item["label"] = clean_label
item["path"] = normalized.get("path")
updated = dict(item)
break
if not updated:
raise ValueError("工作区不存在")
payload["workspaces"] = raw_workspaces
_atomic_write_json(cfg_path, payload)
return {
"workspace": updated,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
def delete_host_workspace(
workspace_id: str,
config_path: Optional[Union[str, Path]] = None,
) -> Dict[str, Any]:
ws_id = str(workspace_id or "").strip()
if not ws_id:
raise ValueError("缺少 workspace_id")
cfg_path = _resolve_config_path(config_path)
with _HOST_WORKSPACE_LOCK:
payload = _ensure_config_file(cfg_path, strict=True)
raw_workspaces = payload.get("workspaces")
if not isinstance(raw_workspaces, list):
raw_workspaces = []
kept = []
deleted = None
for idx, item in enumerate(raw_workspaces):
normalized = _normalize_entry(item, idx)
if normalized and normalized.get("workspace_id") == ws_id:
deleted = normalized
continue
kept.append(item)
if not deleted:
raise ValueError("工作区不存在")
if not kept:
raise ValueError("至少保留一个工作区")
payload["workspaces"] = kept
if str(payload.get("default_workspace_id") or "").strip() == ws_id:
first = _normalize_entry(kept[0], 0)
payload["default_workspace_id"] = (first or {}).get("workspace_id") or "default"
_atomic_write_json(cfg_path, payload)
return {
"deleted": deleted,
"catalog": load_host_workspace_catalog(config_path=cfg_path),
}
__all__ = [
"load_host_workspace_catalog",
"resolve_host_workspace",

View File

@ -55,6 +55,41 @@ def infer_private_skills_dir(data_dir: str | Path | None) -> Optional[Path]:
data_path = Path(data_dir).expanduser().resolve()
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
return (data_path.parent.parent.parent / "agentskills").resolve()
# Docker Web 项目化布局:
# users/<user>/projects/<project_id>/data -> users/<user>/personal/agentskills
if data_path.name == "data" and data_path.parent.parent.name == "projects":
user_root = data_path.parent.parent.parent
shared_root = (user_root / "personal" / "agentskills").resolve()
legacy_root = (data_path.parent / "agentskills").resolve()
if legacy_root.exists() and legacy_root.is_dir() and legacy_root != shared_root:
shared_root.mkdir(parents=True, exist_ok=True)
for item in list(legacy_root.iterdir()):
dest = shared_root / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
try:
legacy_root.rmdir()
except Exception:
pass
return shared_root
# 兼容上一轮临时结构:
# users/<user>/project/<project_id>/data -> users/<user>/personal/agentskills
if data_path.name == "data" and data_path.parent.parent.name == "project":
user_root = data_path.parent.parent.parent
shared_root = (user_root / "personal" / "agentskills").resolve()
temp_shared_root = (data_path.parent.parent / "shared" / "agentskills").resolve()
for legacy_root in (temp_shared_root, data_path.parent / "agentskills"):
if legacy_root.exists() and legacy_root.is_dir() and legacy_root != shared_root:
shared_root.mkdir(parents=True, exist_ok=True)
for item in list(legacy_root.iterdir()):
dest = shared_root / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
try:
legacy_root.rmdir()
except Exception:
pass
return shared_root
return (data_path.parent / "agentskills").resolve()
except Exception:
return None

View File

@ -2,6 +2,7 @@
import json
import re
import shutil
from dataclasses import dataclass
from datetime import datetime
from pathlib import Path
@ -16,8 +17,11 @@ from config import (
USER_SPACE_DIR,
USERS_DB_FILE,
UPLOAD_QUARANTINE_SUBDIR,
MAIN_MEMORY_FILE,
TASK_MEMORY_FILE,
)
from modules.personalization_manager import ensure_personalization_config
from modules.personalization_manager import PERSONALIZATION_FILENAME
@dataclass
@ -46,6 +50,7 @@ class UserManager:
"""Handle user registration, authentication and workspace provisioning."""
USERNAME_REGEX = re.compile(r"^[a-z0-9_\-]{3,32}$")
RESERVED_WORKSPACE_IDS = {"default", "personal", "shared"}
def __init__(
self,
@ -119,12 +124,221 @@ class UserManager:
def get_user(self, username: str) -> Optional[UserRecord]:
return self._users.get((username or "").strip().lower())
def ensure_user_workspace(self, username: str) -> UserWorkspace:
username = self._normalize_username(username)
@staticmethod
def normalize_workspace_id(workspace_id: str) -> str:
candidate = (workspace_id or "default").strip()
if not candidate:
candidate = "default"
if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", candidate):
raise ValueError("项目 ID 只能包含字母、数字、点、下划线或连字符,长度 1-40。")
return candidate
@classmethod
def validate_new_workspace_id(cls, workspace_id: str) -> str:
candidate = cls.normalize_workspace_id(workspace_id)
if candidate in cls.RESERVED_WORKSPACE_IDS or candidate.startswith("."):
raise ValueError("该项目名称已被系统保留,请换一个名称。")
return candidate
def _project_container_root(self, username: str) -> Path:
return (self.workspace_root / username / "projects").resolve()
def _personal_root(self, username: str) -> Path:
return (self.workspace_root / username / "personal").resolve()
@staticmethod
def _looks_like_project_workspace_dir(path: Path) -> bool:
return path.is_dir() and ((path / "project").exists() or (path / "data").exists())
def _move_dir_contents(self, source: Path, target: Path) -> None:
if not source.exists() or not source.is_dir():
return
target.mkdir(parents=True, exist_ok=True)
for child in list(source.iterdir()):
dest = target / child.name
if dest.exists():
continue
shutil.move(str(child), str(dest))
def _ensure_shared_file_link(self, link_path: Path, target_path: Path) -> None:
target_path.parent.mkdir(parents=True, exist_ok=True)
link_path.parent.mkdir(parents=True, exist_ok=True)
if link_path.is_symlink():
try:
if link_path.resolve() == target_path.resolve():
return
except Exception:
pass
try:
link_path.unlink()
except Exception:
return
if link_path.exists():
if not link_path.is_file():
return
try:
should_adopt_existing = (
not target_path.exists()
or (target_path.is_file() and target_path.stat().st_size == 0)
)
except Exception:
should_adopt_existing = not target_path.exists()
if should_adopt_existing:
try:
shutil.copy2(str(link_path), str(target_path))
except Exception:
return
try:
link_path.unlink()
except Exception:
return
if not target_path.exists():
target_path.touch()
try:
link_path.symlink_to(target_path)
except Exception:
if not link_path.exists() and target_path.exists():
try:
shutil.copy2(target_path, link_path)
except Exception:
pass
def _ensure_shared_user_state_links(self, username: str, data_dir: Path) -> None:
"""项目间共享用户级个性化与记忆,同时保持 data_dir 路径兼容。"""
personal_dir = self._personal_root(username)
personalization_dir = personal_dir / "personalization"
memory_dir = personal_dir / "memory"
personalization_dir.mkdir(parents=True, exist_ok=True)
memory_dir.mkdir(parents=True, exist_ok=True)
self._ensure_shared_file_link(
data_dir / PERSONALIZATION_FILENAME,
personalization_dir / PERSONALIZATION_FILENAME,
)
ensure_personalization_config(personalization_dir)
self._ensure_shared_file_link(
data_dir / Path(MAIN_MEMORY_FILE).name,
memory_dir / Path(MAIN_MEMORY_FILE).name,
)
self._ensure_shared_file_link(
data_dir / Path(TASK_MEMORY_FILE).name,
memory_dir / Path(TASK_MEMORY_FILE).name,
)
def _migrate_legacy_docker_workspace_layout(self, username: str) -> None:
"""把旧 Docker Web 单项目目录迁移为 projects/default/{project,data,logs}。
旧布局
<user>/project/*
<user>/data/*
<user>/logs/*
新布局
<user>/projects/default/project/*
<user>/projects/default/data/*
<user>/projects/default/logs/*
<user>/personal/{personalization,memory,agentskills}
"""
root = (self.workspace_root / username).resolve()
project_path = root / "project"
data_dir = root / "data"
logs_dir = root / "logs"
projects_root = self._project_container_root(username)
personal_root = self._personal_root(username)
legacy_project_root = root / "project"
default_root = projects_root / "default"
default_project = default_root / "project"
default_data = default_root / "data"
default_logs = default_root / "logs"
marker = default_root / ".migrated_from_legacy"
# 兼容上一轮临时结构 users/<user>/project/<id> 与 users/<user>/project/shared。
if legacy_project_root.exists() and legacy_project_root.is_dir():
projects_root.mkdir(parents=True, exist_ok=True)
legacy_shared = legacy_project_root / "shared"
if legacy_shared.exists() and legacy_shared.is_dir():
for sub in ("personalization", "memory", "agentskills"):
self._move_dir_contents(legacy_shared / sub, personal_root / sub)
try:
shutil.rmtree(legacy_shared, ignore_errors=True)
except Exception:
pass
for item in list(legacy_project_root.iterdir()):
if item.name.startswith("."):
continue
if item.name in self.RESERVED_WORKSPACE_IDS - {"default"}:
continue
if self._looks_like_project_workspace_dir(item):
dest = projects_root / item.name
if not dest.exists():
shutil.move(str(item), str(dest))
# 迁移最老布局 users/<user>/project/* 下的实际文件到 default/project。
if legacy_project_root.exists() and not marker.exists():
legacy_children = [
child
for child in list(legacy_project_root.iterdir())
if (
not child.name.startswith(".")
and not (child.is_dir() and child.name in (self.RESERVED_WORKSPACE_IDS - {"project"}))
and not self._looks_like_project_workspace_dir(child)
)
]
if legacy_children:
default_project.mkdir(parents=True, exist_ok=True)
for child in legacy_children:
dest = default_project / child.name
if not dest.exists():
shutil.move(str(child), str(dest))
try:
legacy_project_root.rmdir()
except Exception:
pass
# 迁移旧 data/logs。
legacy_data = root / "data"
legacy_logs = root / "logs"
if legacy_data.exists() and legacy_data.is_dir() and legacy_data.resolve() != default_data.resolve():
if not default_data.exists():
default_data.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(legacy_data), str(default_data))
else:
self._move_dir_contents(legacy_data, default_data)
try:
legacy_data.rmdir()
except Exception:
pass
if legacy_logs.exists() and legacy_logs.is_dir() and legacy_logs.resolve() != default_logs.resolve():
if not default_logs.exists():
default_logs.parent.mkdir(parents=True, exist_ok=True)
shutil.move(str(legacy_logs), str(default_logs))
else:
self._move_dir_contents(legacy_logs, default_logs)
try:
legacy_logs.rmdir()
except Exception:
pass
legacy_agentskills = root / "agentskills"
if legacy_agentskills.exists() and legacy_agentskills.is_dir():
self._move_dir_contents(legacy_agentskills, personal_root / "agentskills")
try:
legacy_agentskills.rmdir()
except Exception:
pass
try:
default_root.mkdir(parents=True, exist_ok=True)
marker.write_text("ok", encoding="utf-8")
except Exception:
pass
def ensure_user_workspace(self, username: str, workspace_id: str = "default") -> UserWorkspace:
username = self._normalize_username(username)
workspace_id = self.normalize_workspace_id(workspace_id)
root = (self.workspace_root / username).resolve()
self._migrate_legacy_docker_workspace_layout(username)
workspace_root = self._project_container_root(username) / workspace_id
project_path = workspace_root / "project"
data_dir = workspace_root / "data"
logs_dir = workspace_root / "logs"
uploads_dir = project_path / ".agents" / "user_upload"
skills_dir = project_path / ".agents" / "skills"
@ -134,23 +348,152 @@ class UserManager:
# 初始化数据子目录
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
ensure_personalization_config(data_dir)
self._ensure_shared_user_state_links(username, data_dir)
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute():
quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
quarantine_dir = (quarantine_root / username).resolve()
quarantine_dir = (quarantine_root / username / workspace_id).resolve()
quarantine_dir.mkdir(parents=True, exist_ok=True)
return UserWorkspace(
workspace = UserWorkspace(
username=username,
root=root,
root=workspace_root,
project_path=project_path,
data_dir=data_dir,
logs_dir=logs_dir,
uploads_dir=uploads_dir,
quarantine_dir=quarantine_dir,
)
workspace.workspace_id = workspace_id
return workspace
def list_user_workspaces(self, username: str) -> Dict[str, Dict]:
username = self._normalize_username(username)
self._migrate_legacy_docker_workspace_layout(username)
result: Dict[str, Dict] = {}
projects_root = self._project_container_root(username)
default_project = projects_root / "default" / "project"
default_data = projects_root / "default" / "data"
if default_project.exists() or default_data.exists():
result["default"] = {
"workspace_id": "default",
"label": "默认项目",
"path": "",
"is_default": True,
"has_conversations": (default_data / "conversations").exists(),
}
else:
# 新用户也始终暴露默认项目,首次访问时再创建目录。
result["default"] = {
"workspace_id": "default",
"label": "默认项目",
"path": "",
"is_default": True,
"has_conversations": False,
}
if projects_root.exists():
for item in sorted(projects_root.iterdir(), key=lambda p: p.name.lower()):
if not item.is_dir():
continue
ws_id = item.name
if ws_id in self.RESERVED_WORKSPACE_IDS or ws_id.startswith("."):
continue
data_dir = item / "data"
result[ws_id] = {
"workspace_id": ws_id,
"label": self._load_workspace_label(item, ws_id),
"path": "",
"is_default": False,
"has_conversations": (data_dir / "conversations").exists(),
}
return result
def generate_workspace_id(self, username: str, label: str = "") -> str:
username = self._normalize_username(username)
raw = (label or "project").strip().lower()
slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
if not slug or slug in self.RESERVED_WORKSPACE_IDS or slug.startswith("."):
slug = "project"
slug = slug[:32].strip("-") or "project"
existing = set(self.list_user_workspaces(username).keys())
candidate = slug
idx = 2
while candidate in existing or candidate in self.RESERVED_WORKSPACE_IDS or candidate.startswith("."):
suffix = f"-{idx}"
candidate = f"{slug[: max(1, 40 - len(suffix))]}{suffix}"
idx += 1
return candidate
def create_user_workspace(self, username: str, workspace_id: str = "", label: str = "") -> UserWorkspace:
ws_id = (
self.validate_new_workspace_id(workspace_id)
if workspace_id
else self.generate_workspace_id(username, label)
)
workspace = self.ensure_user_workspace(username, ws_id)
label_text = (label or "").strip()
if label_text:
meta_path = Path(workspace.root) / "project.json"
try:
meta_path.write_text(
json.dumps({"workspace_id": ws_id, "label": label_text}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
except Exception:
pass
return workspace
def rename_user_workspace(self, username: str, workspace_id: str, label: str) -> Dict:
username = self._normalize_username(username)
ws_id = self.normalize_workspace_id(workspace_id)
label_text = (label or "").strip()
if not label_text:
raise ValueError("项目名称不能为空")
known = self.list_user_workspaces(username)
if ws_id not in known:
raise ValueError("项目不存在")
workspace = self.ensure_user_workspace(username, ws_id)
meta_path = Path(workspace.root) / "project.json"
meta_path.write_text(
json.dumps({"workspace_id": ws_id, "label": label_text}, ensure_ascii=False, indent=2),
encoding="utf-8",
)
return {
"workspace_id": ws_id,
"label": label_text,
"path": "",
"is_default": ws_id == "default",
"has_conversations": (Path(workspace.data_dir) / "conversations").exists(),
}
def delete_user_workspace(self, username: str, workspace_id: str) -> None:
username = self._normalize_username(username)
ws_id = self.validate_new_workspace_id(workspace_id)
projects_root = self._project_container_root(username)
target = (projects_root / ws_id).resolve()
try:
target.relative_to(projects_root.resolve())
except ValueError as exc:
raise ValueError("项目路径不合法") from exc
if not target.exists() or not target.is_dir():
raise ValueError("项目不存在")
shutil.rmtree(target)
@staticmethod
def _load_workspace_label(workspace_root: Path, fallback: str) -> str:
meta_path = workspace_root / "project.json"
if meta_path.exists():
try:
data = json.loads(meta_path.read_text(encoding="utf-8"))
label = str(data.get("label") or "").strip()
if label:
return label
except Exception:
pass
return fallback
def list_invite_codes(self):
return list(self._invites.values())

View File

@ -1,5 +1,6 @@
## 工作区信息
- **项目名称**{project_name}
- **运行环境**{runtime_environment}
- **资源限制**{resource_limit}
- **当前时间**{current_time}

View File

@ -182,6 +182,7 @@ def login():
session['username'] = record.username
session['role'] = record.role or 'user'
session['host_mode'] = False
session['workspace_id'] = 'default'
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast")
@ -192,7 +193,12 @@ def login():
_issue_login_nonce(record.username)
clear_failures("login", identifier=client_ip)
try:
state.container_manager.ensure_container(record.username, str(workspace.project_path), preferred_mode="docker")
state.container_manager.ensure_container(
record.username,
str(workspace.project_path),
container_key=f"{record.username}::default",
preferred_mode="docker",
)
except RuntimeError as exc:
session.clear()
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503
@ -241,7 +247,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
@ -457,7 +468,10 @@ def serve_user_upload(filename: str):
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = state.user_manager.ensure_user_workspace(user.username)
workspace = state.user_manager.ensure_user_workspace(
user.username,
session.get("workspace_id") or "default",
)
uploads_dir = workspace.uploads_dir.resolve()
target = (uploads_dir / filename).resolve()
try:
@ -475,7 +489,10 @@ def serve_workspace_file(filename: str):
user = get_current_user_record()
if not user:
return redirect('/login')
workspace = state.user_manager.ensure_user_workspace(user.username)
workspace = state.user_manager.ensure_user_workspace(
user.username,
session.get("workspace_id") or "default",
)
project_root = workspace.project_path.resolve()
target = (project_root / filename).resolve()
try:

View File

@ -50,6 +50,19 @@ def _make_terminal_key(username: str, workspace_id: Optional[str] = None) -> str
return f"{username}::{workspace_id}" if workspace_id else username
def _set_terminal_workspace_label(terminal: WebTerminal, label: Optional[str]) -> None:
label_text = str(label or "").strip()
try:
terminal.workspace_label = label_text
except Exception:
pass
try:
if getattr(terminal, "context_manager", None):
terminal.context_manager.workspace_label = label_text
except Exception:
pass
def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None:
"""
确保工作区 skills 已按当前个性化配置完成同步
@ -90,15 +103,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 +162,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)
@ -231,6 +255,10 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if has_request_context():
session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['host_workspace_id'] = getattr(workspace, "workspace_id", None)
_set_terminal_workspace_label(
terminal,
host_workspace.get("label") or host_workspace.get("workspace_id") or getattr(workspace, "workspace_id", None),
)
# 宿主机模式同样需要应用管理员策略(否则前端工具菜单会退化成静态基础分类)
try:
@ -293,14 +321,20 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
workspace = state.api_user_manager.ensure_workspace(username, workspace_id)
else:
record = get_current_user_record()
workspace = state.user_manager.ensure_user_workspace(username)
selected_workspace_id = (
workspace_id
or (session.get("workspace_id") if has_request_context() else None)
or "default"
)
workspace = state.user_manager.ensure_user_workspace(username, selected_workspace_id)
# 为兼容后续逻辑,补充 workspace_id 属性
if not hasattr(workspace, "workspace_id"):
try:
workspace.workspace_id = "default"
workspace.workspace_id = selected_workspace_id or "default"
except Exception:
pass
term_key = _make_terminal_key(username, getattr(workspace, "workspace_id", None) if is_api_user else None)
workspace_id_value = getattr(workspace, "workspace_id", None) or "default"
term_key = _make_terminal_key(username, workspace_id_value)
container_handle = state.container_manager.ensure_container(username, str(workspace.project_path), container_key=term_key, preferred_mode="docker")
usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace)
terminal = state.user_terminals.get(term_key)
@ -344,17 +378,29 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
session['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None)
if is_api_user:
session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['workspace_id'] = getattr(workspace, "workspace_id", None)
else:
terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username)
terminal.username = username
terminal.user_role = "api" if is_api_user else get_current_user_role(record)
terminal.quota_update_callback = (lambda metric=None: emit_user_quota_update(username)) if not is_api_user else None
if has_request_context() and is_api_user:
if has_request_context():
session['workspace_id'] = getattr(workspace, "workspace_id", None)
if is_api_user:
workspace_label = workspace_id_value
else:
try:
workspace_label = (
state.user_manager.list_user_workspaces(username)
.get(workspace_id_value, {})
.get("label")
) or workspace_id_value
except Exception:
workspace_label = workspace_id_value
_set_terminal_workspace_label(terminal, workspace_label)
# 应用管理员策略
if not is_api_user:
try:

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:
@ -202,9 +245,10 @@ def _resolve_input_draft_path(workspace: UserWorkspace, username: str) -> Tuple[
filename = f"{_sanitize_scope_component(workspace_id)}.json"
return (base_dir / "host" / filename).resolve(), scope
safe_user = _sanitize_scope_component(username or "user", default="user")
scope = f"user:{safe_user}"
# docker 模式下 data_dir 本身已按用户隔离,这里固定单文件即可。
return (base_dir / "docker" / "draft.json").resolve(), scope
workspace_id = getattr(workspace, "workspace_id", None) or session.get("workspace_id") or "default"
safe_workspace = _sanitize_scope_component(workspace_id)
scope = f"user:{safe_user}:project:{safe_workspace}"
return (base_dir / "docker" / f"{safe_workspace}.json").resolve(), scope
def _read_input_draft_payload(path: Path) -> Dict[str, Any]:
@ -393,19 +437,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 +490,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 +554,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 +609,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

@ -17,7 +17,9 @@ from .state import (
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE
from modules.host_workspace_manager import (
create_host_workspace,
delete_host_workspace,
load_host_workspace_catalog,
rename_host_workspace,
resolve_host_workspace,
)
from utils.host_workspace_debug import write_host_workspace_debug
@ -29,6 +31,46 @@ STATUS_DIAG_VERBOSE = os.environ.get("STATUS_DIAG_VERBOSE", "0") not in {"0", "f
STATUS_DIAG_SLOW_MS = int(os.environ.get("STATUS_DIAG_SLOW_MS", "1200") or 1200)
def _close_terminal_for_key(term_key: str):
terminal = state.user_terminals.pop(term_key, None)
if terminal:
try:
if getattr(terminal, "terminal_manager", None):
terminal.terminal_manager.close_all()
except Exception:
pass
def _build_docker_projects_payload(username: str, current_id: str) -> list[dict]:
running_by_workspace = _active_task_counts(username)
projects = []
for ws_id, item in user_manager.list_user_workspaces(username).items():
projects.append({
"workspace_id": ws_id,
"label": item.get("label") or ("默认项目" if ws_id == "default" else ws_id),
"path": "",
"is_current": ws_id == current_id,
"is_default": ws_id == "default",
"running_task_count": int(running_by_workspace.get(ws_id, 0)),
})
return projects
def _build_host_workspaces_payload(catalog: dict, current_id: str) -> list[dict]:
running_by_workspace = _active_task_counts("host")
workspaces = []
for item in catalog.get("workspaces") or []:
ws_id = item.get("workspace_id")
workspaces.append({
"workspace_id": ws_id,
"label": item.get("label"),
"path": item.get("path"),
"is_current": ws_id == current_id,
"running_task_count": int(running_by_workspace.get(ws_id, 0)),
})
return workspaces
def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]:
"""从 android-webview-app/app/build.gradle.kts 读取 versionCode/versionName。"""
gradle_file = project_root / "android-webview-app" / "app" / "build.gradle.kts"
@ -69,6 +111,23 @@ def _is_host_mode_request() -> bool:
return bool(session.get("host_mode")) and (TERMINAL_SANDBOX_MODE or "").lower() == "host"
def _is_docker_project_request() -> bool:
return bool(session.get("username")) and not bool(session.get("host_mode")) and not bool(session.get("is_api_user"))
def _active_task_counts(username: str) -> dict:
running_by_workspace = {}
try:
from .tasks import task_manager
for rec in task_manager.list_tasks(username):
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 = {}
return running_by_workspace
@status_bp.route('/api/health')
@api_login_required
def get_health():
@ -149,7 +208,13 @@ def get_status(terminal, workspace, username):
try:
# 首屏状态只需要容器是否存在/运行等轻量信息Docker stats 很慢,
# 由 /api/container-status 在用量面板需要时单独拉取。
status['container'] = container_manager.get_container_status(username, include_stats=False)
workspace_id = getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'
container_key = (
f"host::{workspace_id}"
if bool(session.get("host_mode")) or username == "host"
else f"{username}::{workspace_id}"
)
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,14 +259,7 @@ def list_host_workspaces():
session['host_workspace_id'] = current_id
session['workspace_id'] = current_id
workspaces = []
for item in catalog.get("workspaces") or []:
workspaces.append({
"workspace_id": item.get("workspace_id"),
"label": item.get("label"),
"path": item.get("path"),
"is_current": item.get("workspace_id") == current_id,
})
workspaces = _build_host_workspaces_payload(catalog, current_id)
return jsonify({
"success": True,
@ -214,6 +272,194 @@ def list_host_workspaces():
})
@status_bp.route('/api/projects')
@api_login_required
def list_docker_projects():
if not _is_docker_project_request():
return jsonify({"success": False, "error": "仅 Docker Web 模式可用"}), 403
username = session.get("username")
current_id = (session.get("workspace_id") or "default").strip() or "default"
# 默认项目用于向后兼容旧 Docker Web 文件/对话。
user_manager.ensure_user_workspace(username, current_id)
projects = _build_docker_projects_payload(username, current_id)
return jsonify({
"success": True,
"data": {
"default_workspace_id": "default",
"current_workspace_id": current_id,
"workspaces": projects,
}
})
@status_bp.route('/api/projects/select', methods=['GET', 'POST'])
@api_login_required
def select_docker_project():
if not _is_docker_project_request():
return jsonify({"success": False, "error": "仅 Docker Web 模式可用"}), 403
payload = request.get_json(silent=True) if request.method != "GET" else None
workspace_id = (
request.args.get("workspace_id")
or (payload or {}).get("workspace_id")
or ""
).strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少项目 ID"}), 400
username = session.get("username")
previous_workspace_id = session.get("workspace_id") or "default"
known_projects = user_manager.list_user_workspaces(username)
if workspace_id not in known_projects:
return jsonify({"success": False, "error": "项目不存在"}), 404
try:
workspace = user_manager.ensure_user_workspace(username, workspace_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
session["workspace_id"] = getattr(workspace, "workspace_id", workspace_id)
try:
container_manager.ensure_container(
username,
str(workspace.project_path),
container_key=f"{username}::{session['workspace_id']}",
preferred_mode="docker",
)
except RuntimeError as exc:
session["workspace_id"] = previous_workspace_id
return jsonify({"success": False, "error": str(exc)}), 503
terminal = state.user_terminals.get(f"{username}::{session['workspace_id']}")
if terminal:
try:
attach_user_broadcast(terminal, username)
except Exception:
pass
return jsonify({
"success": True,
"data": {
"current_workspace_id": session["workspace_id"],
"project_path": "",
"default_workspace_id": "default",
"reloaded": previous_workspace_id != session["workspace_id"],
}
})
@status_bp.route('/api/projects/create', methods=['POST'])
@api_login_required
def create_docker_project():
if not _is_docker_project_request():
return jsonify({"success": False, "error": "仅 Docker Web 模式可用"}), 403
payload = request.get_json(silent=True) or {}
label = (payload.get("label") or payload.get("name") or "").strip()
if not label:
return jsonify({"success": False, "error": "项目名称不能为空"}), 400
username = session.get("username")
try:
workspace = user_manager.create_user_workspace(username, "", label=label)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
projects = []
current_id = session.get("workspace_id") or "default"
for ws_id, item in user_manager.list_user_workspaces(username).items():
projects.append({
"workspace_id": ws_id,
"label": item.get("label") or ws_id,
"path": "",
"is_current": ws_id == current_id,
"is_default": ws_id == "default",
})
return jsonify({
"success": True,
"data": {
"created": True,
"workspace": {
"workspace_id": getattr(workspace, "workspace_id", ""),
"label": label,
"path": "",
},
"default_workspace_id": "default",
"current_workspace_id": current_id,
"workspaces": projects,
}
})
@status_bp.route('/api/projects/rename', methods=['POST'])
@api_login_required
def rename_docker_project():
if not _is_docker_project_request():
return jsonify({"success": False, "error": "仅 Docker Web 模式可用"}), 403
payload = request.get_json(silent=True) or {}
workspace_id = (payload.get("workspace_id") or "").strip()
label = (payload.get("label") or payload.get("name") or "").strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少项目 ID"}), 400
if not label:
return jsonify({"success": False, "error": "项目名称不能为空"}), 400
username = session.get("username")
try:
workspace = user_manager.rename_user_workspace(username, workspace_id, label)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
current_id = session.get("workspace_id") or "default"
return jsonify({
"success": True,
"data": {
"workspace": workspace,
"default_workspace_id": "default",
"current_workspace_id": current_id,
"workspaces": _build_docker_projects_payload(username, current_id),
}
})
@status_bp.route('/api/projects/delete', methods=['POST'])
@api_login_required
def delete_docker_project():
if not _is_docker_project_request():
return jsonify({"success": False, "error": "仅 Docker Web 模式可用"}), 403
payload = request.get_json(silent=True) or {}
workspace_id = (payload.get("workspace_id") or "").strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少项目 ID"}), 400
if workspace_id == "default":
return jsonify({"success": False, "error": "默认项目不能删除"}), 400
username = session.get("username")
if _active_task_counts(username).get(workspace_id):
return jsonify({"success": False, "error": "该项目有运行中的任务,暂不能删除"}), 409
known_projects = user_manager.list_user_workspaces(username)
if workspace_id not in known_projects:
return jsonify({"success": False, "error": "项目不存在"}), 404
term_key = f"{username}::{workspace_id}"
try:
_close_terminal_for_key(term_key)
container_manager.release_container(term_key, reason="delete_project")
user_manager.delete_user_workspace(username, workspace_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
current_id = session.get("workspace_id") or "default"
if current_id == workspace_id:
current_id = "default"
session["workspace_id"] = current_id
try:
workspace = user_manager.ensure_user_workspace(username, current_id)
container_manager.ensure_container(
username,
str(workspace.project_path),
container_key=f"{username}::{current_id}",
preferred_mode="docker",
)
except RuntimeError as exc:
return jsonify({"success": False, "error": str(exc)}), 503
return jsonify({
"success": True,
"data": {
"deleted_workspace_id": workspace_id,
"default_workspace_id": "default",
"current_workspace_id": current_id,
"workspaces": _build_docker_projects_payload(username, current_id),
}
})
@status_bp.route('/api/host/workspaces/select', methods=['GET', 'POST'])
@api_login_required
def select_host_workspace():
@ -250,60 +496,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 +508,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 +526,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 +547,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 +564,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,
}
})
@ -434,12 +625,97 @@ def create_host_workspace_api():
})
@status_bp.route('/api/host/workspaces/rename', methods=['POST'])
@api_login_required
def rename_host_workspace_api():
if not _is_host_mode_request():
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
payload = request.get_json(silent=True) or {}
workspace_id = (payload.get("workspace_id") or "").strip()
label = (payload.get("label") or payload.get("name") or "").strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少 workspace_id"}), 400
if not label:
return jsonify({"success": False, "error": "工作区名称不能为空"}), 400
try:
result = rename_host_workspace(workspace_id, label)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
catalog = result.get("catalog") or load_host_workspace_catalog()
current_id = (session.get("host_workspace_id") or "").strip()
return jsonify({
"success": True,
"data": {
"workspace": result.get("workspace") or {},
"source_path": catalog.get("source_path"),
"default_workspace_id": catalog.get("default_workspace_id"),
"current_workspace_id": current_id,
"workspaces": _build_host_workspaces_payload(catalog, current_id),
}
})
@status_bp.route('/api/host/workspaces/delete', methods=['POST'])
@api_login_required
def delete_host_workspace_api():
if not _is_host_mode_request():
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
payload = request.get_json(silent=True) or {}
workspace_id = (payload.get("workspace_id") or "").strip()
if not workspace_id:
return jsonify({"success": False, "error": "缺少 workspace_id"}), 400
if _active_task_counts("host").get(workspace_id):
return jsonify({"success": False, "error": "该工作区有运行中的任务,暂不能删除"}), 409
try:
_close_terminal_for_key(f"host::{workspace_id}")
container_manager.release_container(f"host::{workspace_id}", reason="delete_host_workspace")
result = delete_host_workspace(workspace_id)
except ValueError as exc:
return jsonify({"success": False, "error": str(exc)}), 400
except Exception as exc:
return jsonify({"success": False, "error": str(exc)}), 500
catalog = result.get("catalog") or load_host_workspace_catalog()
current_id = (session.get("host_workspace_id") or "").strip()
if current_id == workspace_id or not any(
item.get("workspace_id") == current_id for item in (catalog.get("workspaces") or [])
):
_, current = resolve_host_workspace(catalog.get("default_workspace_id"))
current_id = current.get("workspace_id") or catalog.get("default_workspace_id") or "default"
current_path = str(Path(current.get("path") or "").expanduser().resolve())
session["host_workspace_id"] = current_id
session["workspace_id"] = current_id
with state.HOST_ACTIVE_WORKSPACE_LOCK:
state.HOST_ACTIVE_WORKSPACE_ID = current_id
state.HOST_ACTIVE_WORKSPACE_PATH = current_path
state.HOST_ACTIVE_WORKSPACE_VERSION += 1
return jsonify({
"success": True,
"data": {
"deleted_workspace_id": workspace_id,
"source_path": catalog.get("source_path"),
"default_workspace_id": catalog.get("default_workspace_id"),
"current_workspace_id": current_id,
"workspaces": _build_host_workspaces_payload(catalog, current_id),
}
})
@status_bp.route('/api/container-status')
@api_login_required
@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 f"{username}::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}"
)
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
@ -450,7 +726,9 @@ def get_container_status_api(terminal, workspace, username):
@with_terminal
def get_project_storage(terminal, workspace, username):
now = time.time()
cache_entry = PROJECT_STORAGE_CACHE.get(username)
workspace_id = getattr(workspace, "workspace_id", None) or session.get("workspace_id") or "default"
cache_key = f"{username}::{workspace_id}"
cache_entry = PROJECT_STORAGE_CACHE.get(cache_key)
if cache_entry and (now - cache_entry.get("ts", 0)) < PROJECT_STORAGE_CACHE_TTL_SECONDS:
return jsonify({"success": True, "data": cache_entry["data"]})
try:
@ -466,10 +744,10 @@ def get_project_storage(terminal, workspace, username):
"limit_label": f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制",
"usage_percent": usage_percent
}
PROJECT_STORAGE_CACHE[username] = {"ts": now, "data": data}
PROJECT_STORAGE_CACHE[cache_key] = {"ts": now, "data": data}
return jsonify({"success": True, "data": data})
except Exception as exc:
stale = PROJECT_STORAGE_CACHE.get(username)
stale = PROJECT_STORAGE_CACHE.get(cache_key)
if stale:
return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200
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,83 @@ 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:
from . import state
workspace = state.user_manager.ensure_user_workspace(rec.username, rec.workspace_id or "default")
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)
else:
from . import state
item = state.user_manager.list_user_workspaces(rec.username).get(rec.workspace_id)
if item:
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 +863,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 +949,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"
@ -93,7 +96,8 @@
:panel-menu-open="panelMenuOpen"
:panel-mode="panelMode"
:file-manager-disabled="policyUiBlocks.block_file_manager"
:host-workspace-enabled="versioningHostMode"
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId"
@ -104,6 +108,7 @@
@toggle-thinking-mode="handleQuickModeToggle"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
@manage-host-workspace="openHostWorkspaceManageDialog"
/>
<div
@ -262,7 +267,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"
@ -470,12 +475,29 @@
:label-value="hostWorkspaceCreateLabel"
:submitting="hostWorkspaceCreateSubmitting"
:error-message="hostWorkspaceCreateError"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
@close="closeHostWorkspaceCreateDialog"
@submit="submitHostWorkspaceCreate"
@update:path-value="hostWorkspaceCreatePath = $event"
@update:label-value="hostWorkspaceCreateLabel = $event"
/>
</transition>
<transition name="overlay-fade">
<HostWorkspaceManageDialog
v-if="hostWorkspaceManageDialogOpen"
:workspaces="hostWorkspaces"
:current-workspace-id="currentHostWorkspaceId"
:default-workspace-id="defaultHostWorkspaceId"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:busy="hostWorkspaceCreateSubmitting || hostWorkspaceManageSubmitting"
:create-submitting="hostWorkspaceCreateSubmitting"
:error-message="hostWorkspaceCreateError"
@close="closeHostWorkspaceManageDialog"
@create="submitHostWorkspaceCreateFromManage"
@rename="renameHostWorkspace"
@delete="deleteHostWorkspace"
/>
</transition>
<TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt
:visible="tutorialPromptVisible"
@ -668,6 +690,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 +702,7 @@
@search-submit="handleSidebarSearchSubmit"
@search-more="loadMoreSearchResults"
@select="handleMobileOverlaySelect($event)"
@select-running-task="openRunningWorkspaceTask"
@load-more="loadMoreConversations"
@personal="openPersonalPage"
@delete="deleteConversation"
@ -714,7 +739,8 @@
:is-connected="isConnected"
:panel-menu-open="panelMenuOpen"
:panel-mode="panelMode"
:host-workspace-enabled="versioningHostMode"
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId"
@ -724,6 +750,7 @@
@open-file-manager="openGuiFileManager"
@switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace"
@manage-host-workspace="openHostWorkspaceManageDialog"
/>
</div>
</div>

View File

@ -27,6 +27,9 @@ const VersioningDialog = defineAsyncComponent(
const HostWorkspaceCreateDialog = defineAsyncComponent(
() => import('../components/overlay/HostWorkspaceCreateDialog.vue')
);
const HostWorkspaceManageDialog = defineAsyncComponent(
() => import('../components/overlay/HostWorkspaceManageDialog.vue')
);
const UserQuestionDialog = defineAsyncComponent(
() => import('../components/overlay/UserQuestionDialog.vue')
);
@ -53,6 +56,7 @@ export const appComponents = {
BackgroundCommandDialog,
VersioningDialog,
HostWorkspaceCreateDialog,
HostWorkspaceManageDialog,
UserQuestionDialog,
TutorialOverlay,
NewUserTutorialPrompt

View File

@ -58,13 +58,16 @@ export const computed = {
'searchResults',
'conversationInsertAnimations',
'conversationListAnimationMode',
'pendingDeletingConversationIds',
'searchActive',
'searchInProgress',
'searchMoreAvailable',
'searchOffset',
'searchTotal',
'conversationsOffset',
'conversationsLimit'
'conversationsLimit',
'runningWorkspaceTasks',
'acknowledgedCompletedTaskIds'
]),
...mapWritableState(useModelStore, ['currentModelKey']),
...mapState(useModelStore, ['models']),
@ -168,7 +171,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.dockerProjectMode) || !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 +201,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({
@ -686,17 +631,31 @@ export const conversationMethods = {
history.replaceState({}, '', '/new');
}
// 先从本地列表移除,让侧边栏执行删除动画;随后再刷新服务端列表补齐分页状态
// 先给目标项加 deleting 类执行左滑动画,再从本地列表移除。
// 这比完全依赖 transition-group leave 更稳定:即使列表即将为空或分支切换,也能先播放离场动画。
this.conversationListAnimationMode = 'delete';
const deletingIds = Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds
: [];
if (!deletingIds.includes(conversationId)) {
this.pendingDeletingConversationIds = [...deletingIds, conversationId];
}
await this.$nextTick();
await waitForAnimation(430);
this.conversations = this.conversations.filter(
(conversation) => conversation.id !== conversationId
);
this.searchResults = this.searchResults.filter(
(conversation) => conversation.id !== conversationId
);
this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds
: []
).filter((id) => id !== conversationId);
// 等待“左滑离场 + 0.1s 后集体上移”动画结束,避免刷新列表打断过渡。
await waitForAnimation(560);
await waitForAnimation(180);
// 刷新对话列表
this.conversationsOffset = 0;
@ -711,6 +670,11 @@ export const conversationMethods = {
});
}
} catch (error) {
this.pendingDeletingConversationIds = (Array.isArray(this.pendingDeletingConversationIds)
? this.pendingDeletingConversationIds
: []
).filter((id) => id !== conversationId);
this.conversationListAnimationMode = 'idle';
console.error('删除对话异常:', error);
this.uiPushToast({
title: '删除对话异常',

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.dockerProjectMode) ||
!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.dockerProjectMode) &&
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;
@ -713,42 +752,238 @@ export const uiMethods = {
},
async fetchHostWorkspaces() {
if (!this.versioningHostMode) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
return;
}
try {
const resp = await fetch('/api/host/workspaces');
const resp = await fetch(this.versioningHostMode ? '/api/host/workspaces' : '/api/projects');
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '获取宿主机工作区列表失败');
throw new Error(payload?.error || '获取项目列表失败');
}
const data = payload.data || {};
const items = Array.isArray(data.workspaces) ? data.workspaces : [];
this.hostWorkspaces = items.map((item: any) => ({
workspace_id: String(item.workspace_id || ''),
label: String(item.label || 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);
console.warn('加载工作区/项目失败:', error);
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
this.runningWorkspaceTasks = [];
}
},
async refreshRunningWorkspaceTasks() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
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 || this.dockerProjectMode) && 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) {
return;
}
if (!this.versioningHostMode) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (targetId === this.currentHostWorkspaceId) {
@ -756,6 +991,14 @@ export const uiMethods = {
}
this.hostWorkspaceSwitching = true;
const previousConversationList = Array.isArray(this.conversations) ? [...this.conversations] : [];
const previousSearchActive = this.searchActive;
this.searchActive = false;
this.searchResults = [];
this.conversations = [];
this.conversationsOffset = 0;
this.hasMoreConversations = false;
this.conversationsLoading = true;
try {
// 先在“当前工作区作用域”落盘草稿,再切换工作区。
// 否则会把旧工作区草稿误写到目标工作区作用域里。
@ -766,10 +1009,11 @@ export const uiMethods = {
}).catch(() => {});
const query = encodeURIComponent(targetId);
const resp = await fetch(`/api/host/workspaces/select?workspace_id=${query}`);
const selectEndpoint = this.versioningHostMode ? '/api/host/workspaces/select' : '/api/projects/select';
const resp = await fetch(`${selectEndpoint}?workspace_id=${query}`);
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '切换宿主机工作区失败');
throw new Error(payload?.error || (this.versioningHostMode ? '切换宿主机工作区失败' : '切换项目失败'));
}
const data = payload.data || {};
this.currentHostWorkspaceId = String(data.current_workspace_id || targetId);
@ -779,18 +1023,48 @@ 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');
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();
}
const switchedWorkspace = (Array.isArray(this.hostWorkspaces) ? this.hostWorkspaces : [])
.find((item: any) => String(item?.workspace_id || '') === this.currentHostWorkspaceId);
const switchedLabel = String(switchedWorkspace?.label || '').trim();
this.uiPushToast({
title: '工作区已切换',
message: '正在刷新页面…',
title: this.versioningHostMode ? '工作区已切换' : '项目已切换',
message: this.versioningHostMode
? (data.project_path || switchedLabel || targetId)
: (switchedLabel || targetId),
type: 'success'
});
window.setTimeout(() => {
window.location.reload();
}, 260);
} catch (error) {
this.conversations = previousConversationList;
this.searchActive = previousSearchActive;
this.conversationsLoading = false;
const message = error instanceof Error ? error.message : String(error || '切换失败');
this.uiPushToast({
title: '切换工作区失败',
title: this.versioningHostMode ? '切换工作区失败' : '切换项目失败',
message,
type: 'error'
});
@ -800,7 +1074,7 @@ export const uiMethods = {
},
async handleCreateHostWorkspace() {
if (!this.versioningHostMode) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
@ -812,6 +1086,23 @@ export const uiMethods = {
this.hostWorkspaceCreateDialogOpen = true;
},
async openHostWorkspaceManageDialog() {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
this.hostWorkspaceCreateError = '';
await this.fetchHostWorkspaces();
this.hostWorkspaceManageDialogOpen = true;
},
closeHostWorkspaceManageDialog() {
if (this.hostWorkspaceCreateSubmitting || this.hostWorkspaceManageSubmitting) {
return;
}
this.hostWorkspaceManageDialogOpen = false;
this.hostWorkspaceCreateError = '';
},
closeHostWorkspaceCreateDialog() {
if (this.hostWorkspaceCreateSubmitting) {
return;
@ -821,34 +1112,38 @@ export const uiMethods = {
},
async submitHostWorkspaceCreate() {
if (!this.versioningHostMode) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return;
}
const workspacePath = String(this.hostWorkspaceCreatePath || '').trim();
if (!workspacePath) {
const label = String(this.hostWorkspaceCreateLabel || '').trim();
if (this.versioningHostMode && !workspacePath) {
this.hostWorkspaceCreateError = '路径不能为空';
return;
}
const label = String(this.hostWorkspaceCreateLabel || '').trim();
if (this.dockerProjectMode && !label) {
this.hostWorkspaceCreateError = '项目名称不能为空';
return;
}
this.hostWorkspaceCreateSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const resp = await fetch('/api/host/workspaces/create', {
const resp = await fetch(this.versioningHostMode ? '/api/host/workspaces/create' : '/api/projects/create', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({
path: workspacePath,
path: this.versioningHostMode ? workspacePath : undefined,
label
})
});
const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '新建宿主机工作区失败');
throw new Error(payload?.error || (this.versioningHostMode ? '新建宿主机工作区失败' : '新建项目失败'));
}
await this.fetchHostWorkspaces();
const createdLabel = payload?.data?.workspace?.label || label || workspacePath;
@ -856,7 +1151,7 @@ export const uiMethods = {
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.uiPushToast({
title: '工作区已创建',
title: this.versioningHostMode ? '工作区已创建' : '项目已创建',
message: createdLabel,
type: 'success'
});
@ -864,7 +1159,7 @@ export const uiMethods = {
const message = error instanceof Error ? error.message : String(error || '新建失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: '新建工作区失败',
title: this.versioningHostMode ? '新建工作区失败' : '新建项目失败',
message,
type: 'error'
});
@ -873,6 +1168,129 @@ export const uiMethods = {
}
},
async submitHostWorkspaceCreateFromManage(payload = {}) {
this.hostWorkspaceCreatePath = String(payload?.path || '').trim();
this.hostWorkspaceCreateLabel = String(payload?.label || '').trim();
await this.submitHostWorkspaceCreate();
},
async renameHostWorkspace(payload = {}) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
const workspaceId = String(payload?.workspace_id || '').trim();
const label = String(payload?.label || '').trim();
if (!workspaceId || !label) {
this.hostWorkspaceCreateError = this.dockerProjectMode ? '项目名称不能为空' : '工作区名称不能为空';
return;
}
this.hostWorkspaceManageSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const endpoint = this.versioningHostMode
? '/api/host/workspaces/rename'
: '/api/projects/rename';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ workspace_id: workspaceId, label })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || !result?.success) {
throw new Error(result?.error || '重命名失败');
}
await this.fetchHostWorkspaces();
this.uiPushToast({
title: this.versioningHostMode ? '工作区已重命名' : '项目已重命名',
message: label,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '重命名失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: this.versioningHostMode ? '重命名工作区失败' : '重命名项目失败',
message,
type: 'error'
});
} finally {
this.hostWorkspaceManageSubmitting = false;
}
},
async deleteHostWorkspace(item) {
if (!(this.versioningHostMode || this.dockerProjectMode)) {
return;
}
const workspaceId = String(item?.workspace_id || '').trim();
if (!workspaceId) return;
const kindLabel = this.versioningHostMode ? '工作区' : '项目';
const wasCurrent = workspaceId === this.currentHostWorkspaceId;
const ok = await this.confirmAction({
title: `删除${kindLabel}`,
message: this.versioningHostMode
? `确定要从列表中删除“${item?.label || workspaceId}”吗?不会删除磁盘上的工作区文件夹。`
: `确定要删除项目“${item?.label || workspaceId}”吗?该项目文件夹和对话记录会被一并删除。`,
confirmText: '删除',
cancelText: '取消',
confirmVariant: 'danger'
});
if (!ok) return;
this.hostWorkspaceManageSubmitting = true;
this.hostWorkspaceCreateError = '';
try {
const endpoint = this.versioningHostMode
? '/api/host/workspaces/delete'
: '/api/projects/delete';
const resp = await fetch(endpoint, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ workspace_id: workspaceId })
});
const result = await resp.json().catch(() => ({}));
if (!resp.ok || !result?.success) {
throw new Error(result?.error || '删除失败');
}
const data = result.data || {};
this.currentHostWorkspaceId = String(data.current_workspace_id || this.currentHostWorkspaceId || '');
if (data.default_workspace_id) {
this.defaultHostWorkspaceId = String(data.default_workspace_id);
}
await this.fetchHostWorkspaces();
if (wasCurrent) {
this.messages = [];
this.currentConversationId = null;
this.currentConversationTitle = '新对话';
this.searchActive = false;
this.searchResults = [];
this.conversations = [];
this.conversationsOffset = 0;
this.hasMoreConversations = false;
this.conversationsLoading = true;
history.replaceState({}, '', '/new');
await this.loadConversationsList();
}
this.uiPushToast({
title: `${kindLabel}已删除`,
message: item?.label || workspaceId,
type: 'success'
});
} catch (error) {
const message = error instanceof Error ? error.message : String(error || '删除失败');
this.hostWorkspaceCreateError = message;
this.uiPushToast({
title: `删除${kindLabel}失败`,
message,
type: 'error'
});
} finally {
this.hostWorkspaceManageSubmitting = false;
}
},
fetchTodoList() {
return this.fileFetchTodoList();
},
@ -2320,7 +2738,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.dockerProjectMode) ||
!this.currentHostWorkspaceId ||
task?.workspace_id === this.currentHostWorkspaceId)
);
return runningTask?.conversation_id || null;
} catch (error) {
@ -2818,19 +3241,18 @@ export const uiMethods = {
let treePromise: Promise<any> | null = null;
const isHostMode = statusData?.container?.mode === 'host';
this.versioningHostMode = !!isHostMode;
this.dockerProjectMode = !isHostMode;
if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
await this.fetchHostWorkspaces();
} else {
this.hostWorkspaces = [];
this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = '';
this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表');
await this.fetchHostWorkspaces();
this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateSubmitting = false;
treePromise = this.fileFetchTree();
}
// 获取当前对话信息

View File

@ -25,6 +25,8 @@ export function dataState() {
taskInProgress: false,
// 等待后台通知期间,用于发现并接管新建任务的轮询定时器
waitingTaskProbeTimer: null,
// 宿主机多工作区任务列表后台刷新定时器(用于当前未查看运行对话时同步完成态)
runningWorkspaceTasksRefreshTimer: null,
// 运行期消息堆积(提前发送 / 引导对话)
runtimeQueuedMessages: [],
runtimeGuidanceFallbackQueue: [],
@ -112,6 +114,7 @@ export function dataState() {
pathAuthorizationDraft: '',
pathAuthorizationSaving: false,
versioningHostMode: false,
dockerProjectMode: false,
hostWorkspaces: [],
currentHostWorkspaceId: '',
defaultHostWorkspaceId: '',
@ -121,6 +124,8 @@ export function dataState() {
hostWorkspaceCreateLabel: '',
hostWorkspaceCreateSubmitting: false,
hostWorkspaceCreateError: '',
hostWorkspaceManageDialogOpen: false,
hostWorkspaceManageSubmitting: false,
versioningEnabled: false,
newConversationVersioningEnabled: false,
versioningTrackingMode: 'workspace_and_conversation',

View File

@ -3,12 +3,14 @@
class="host-workspace-dialog-overlay"
role="dialog"
aria-modal="true"
aria-label="新建工作区"
:aria-label="workspaceKind === 'project' ? '新建项目' : '新建工作区'"
@click.self="handleOverlayClose"
>
<form class="host-workspace-dialog" @submit.prevent="$emit('submit')">
<div class="host-workspace-dialog__header">
<div class="host-workspace-dialog__title">新建工作区</div>
<div class="host-workspace-dialog__title">
{{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }}
</div>
<button
type="button"
class="host-workspace-dialog__close"
@ -20,8 +22,10 @@
</div>
<div class="host-workspace-dialog__body">
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区路径</span>
<label v-if="workspaceKind !== 'project'" class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">
工作区路径
</span>
<input
:value="pathValue"
type="text"
@ -34,12 +38,14 @@
</label>
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区名称可选</span>
<span class="host-workspace-dialog__label">
{{ workspaceKind === 'project' ? '项目名称' : '工作区名称(可选)' }}
</span>
<input
:value="labelValue"
type="text"
class="host-workspace-dialog__input"
placeholder="例如客户A项目"
:placeholder="workspaceKind === 'project' ? '例如:客户 A 项目' : '例如客户A项目'"
autocomplete="off"
:disabled="submitting"
@input="$emit('update:labelValue', ($event.target as HTMLInputElement).value)"
@ -54,7 +60,7 @@
取消
</button>
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="submitting">
{{ submitting ? '创建中...' : '创建工作区' }}
{{ submitting ? '创建中...' : (workspaceKind === 'project' ? '创建项目' : '创建工作区') }}
</button>
</div>
</form>
@ -70,6 +76,7 @@ const props = defineProps<{
labelValue: string;
submitting?: boolean;
errorMessage?: string;
workspaceKind?: 'workspace' | 'project';
}>();
const emits = defineEmits<{
@ -79,6 +86,8 @@ const emits = defineEmits<{
(event: 'update:labelValue', value: string): void;
}>();
const workspaceKind = props.workspaceKind || 'workspace';
const handleOverlayClose = () => {
if (props.submitting) return;
emits('close');

View File

@ -0,0 +1,392 @@
<template>
<div
class="host-workspace-dialog-overlay"
role="dialog"
aria-modal="true"
:aria-label="workspaceKind === 'project' ? '管理项目' : '管理工作区'"
@click.self="handleOverlayClose"
>
<form class="host-workspace-dialog host-workspace-manage" @submit.prevent="submitCreate">
<div class="host-workspace-dialog__header">
<div class="host-workspace-dialog__title">
{{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }}
</div>
<button
type="button"
class="host-workspace-dialog__close"
:disabled="busy"
@click="$emit('close')"
>
关闭
</button>
</div>
<div class="host-workspace-dialog__body">
<section class="workspace-manage-section">
<div class="workspace-manage-section__title">
{{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }}
</div>
<label v-if="workspaceKind !== 'project'" class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区路径</span>
<input
v-model="createPath"
type="text"
class="host-workspace-dialog__input"
placeholder="请输入绝对路径或相对仓库路径"
autocomplete="off"
:disabled="busy"
/>
</label>
<label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">
{{ workspaceKind === 'project' ? '项目名称' : '工作区名称(可选)' }}
</span>
<input
v-model="createLabel"
type="text"
class="host-workspace-dialog__input"
:placeholder="workspaceKind === 'project' ? '例如:客户 A 项目' : '例如客户A项目'"
autocomplete="off"
:disabled="busy"
/>
</label>
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="busy">
{{ createSubmitting ? '创建中...' : (workspaceKind === 'project' ? '新建项目' : '新建工作区') }}
</button>
</section>
<section class="workspace-manage-section">
<div class="workspace-manage-section__title">
{{ workspaceKind === 'project' ? '已有项目' : '已有工作区' }}
</div>
<div v-if="workspaces.length" class="workspace-manage-list">
<div
v-for="item in workspaces"
:key="item.workspace_id"
class="workspace-manage-row"
:class="{ current: item.workspace_id === currentWorkspaceId }"
>
<div class="workspace-manage-main">
<input
class="host-workspace-dialog__input workspace-manage-name"
:value="renameDrafts[item.workspace_id] ?? item.label"
:disabled="busy"
@input="setRenameDraft(item.workspace_id, ($event.target as HTMLInputElement).value)"
/>
<div v-if="workspaceKind !== 'project'" class="workspace-manage-path">
{{ item.path || '(未配置路径)' }}
</div>
<div class="workspace-manage-badges">
<span v-if="item.workspace_id === defaultWorkspaceId" class="workspace-manage-badge">
{{ workspaceKind === 'project' ? '默认项目' : '默认工作区' }}
</span>
<span v-if="item.workspace_id === currentWorkspaceId" class="workspace-manage-badge">
当前
</span>
<span v-if="Number(item.running_task_count || 0) > 0" class="workspace-manage-badge running">
运行中
</span>
</div>
</div>
<div class="workspace-manage-actions">
<button
type="button"
class="host-workspace-dialog__btn ghost"
:disabled="busy || !hasRenameChange(item)"
@click="submitRename(item)"
>
重命名
</button>
<button
type="button"
class="host-workspace-dialog__btn danger"
:disabled="busy || !canDelete(item)"
@click="$emit('delete', item)"
>
删除
</button>
</div>
</div>
</div>
<div v-else class="workspace-manage-empty">
{{ workspaceKind === 'project' ? '暂无项目' : '暂无工作区' }}
</div>
</section>
<div v-if="errorMessage" class="host-workspace-dialog__error">{{ errorMessage }}</div>
</div>
</form>
</div>
</template>
<script setup lang="ts">
import { reactive, ref, watch } from 'vue';
defineOptions({ name: 'HostWorkspaceManageDialog' });
type WorkspaceItem = {
workspace_id: string;
label: string;
path?: string;
running_task_count?: number;
};
const props = defineProps<{
workspaces: WorkspaceItem[];
currentWorkspaceId?: string;
defaultWorkspaceId?: string;
workspaceKind?: 'workspace' | 'project';
busy?: boolean;
createSubmitting?: boolean;
errorMessage?: string;
}>();
const emits = defineEmits<{
(event: 'close'): void;
(event: 'create', payload: { path: string; label: string }): void;
(event: 'rename', payload: { workspace_id: string; label: string }): void;
(event: 'delete', item: WorkspaceItem): void;
}>();
const workspaceKind = props.workspaceKind || 'workspace';
const createPath = ref('');
const createLabel = ref('');
const renameDrafts = reactive<Record<string, string>>({});
watch(
() => props.workspaces,
(items) => {
for (const item of items || []) {
renameDrafts[item.workspace_id] = item.label || item.workspace_id;
}
},
{ immediate: true }
);
const handleOverlayClose = () => {
if (props.busy) return;
emits('close');
};
const setRenameDraft = (workspaceId: string, value: string) => {
renameDrafts[workspaceId] = value;
};
const hasRenameChange = (item: WorkspaceItem) => {
const draft = String(renameDrafts[item.workspace_id] ?? '').trim();
return !!draft && draft !== String(item.label || '').trim();
};
const canDelete = (item: WorkspaceItem) => {
if (Number(item.running_task_count || 0) > 0) return false;
if (workspaceKind === 'project' && item.workspace_id === 'default') return false;
return true;
};
const submitCreate = () => {
emits('create', {
path: createPath.value.trim(),
label: createLabel.value.trim()
});
};
const submitRename = (item: WorkspaceItem) => {
emits('rename', {
workspace_id: item.workspace_id,
label: String(renameDrafts[item.workspace_id] ?? '').trim()
});
};
</script>
<style scoped>
.host-workspace-dialog-overlay {
position: fixed;
inset: 0;
z-index: 1300;
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
background: var(--theme-overlay-scrim);
backdrop-filter: blur(10px);
}
.host-workspace-dialog {
width: min(680px, 96vw);
height: min(760px, 92vh);
border-radius: 18px;
border: 1px solid var(--theme-control-border-strong);
background: var(--theme-surface-card);
box-shadow: var(--theme-shadow-soft);
display: flex;
flex-direction: column;
overflow: hidden;
}
.host-workspace-dialog__header,
.host-workspace-dialog__actions {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 16px 18px;
border-bottom: 1px solid var(--theme-control-border);
}
.host-workspace-dialog__title {
font-size: 16px;
font-weight: 700;
color: var(--claude-text);
}
.host-workspace-dialog__close,
.host-workspace-dialog__btn {
border: 1px solid var(--theme-control-border-strong);
background: transparent;
color: var(--claude-text-secondary);
border-radius: 10px;
padding: 7px 12px;
cursor: pointer;
white-space: nowrap;
}
.host-workspace-dialog__close:disabled,
.host-workspace-dialog__btn:disabled {
opacity: 0.6;
cursor: not-allowed;
}
.host-workspace-dialog__body {
padding: 16px 18px;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
gap: 16px;
overflow: hidden;
}
.workspace-manage-section {
display: grid;
gap: 10px;
}
.workspace-manage-section__title {
font-size: 13px;
font-weight: 700;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__field {
display: grid;
gap: 6px;
}
.host-workspace-dialog__label,
.workspace-manage-path {
font-size: 12px;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__input {
border: 1px solid var(--theme-control-border-strong);
border-radius: 10px;
background: var(--theme-surface-soft);
color: var(--claude-text);
padding: 9px 11px;
font-size: 14px;
line-height: 1.4;
outline: none;
}
.host-workspace-dialog__input:focus {
border-color: var(--claude-accent);
box-shadow: 0 0 0 2px color-mix(in srgb, var(--claude-accent) 20%, transparent);
}
.host-workspace-dialog__btn.primary {
justify-self: flex-start;
background: var(--claude-accent);
border-color: var(--claude-accent-strong);
color: #fff;
}
.host-workspace-dialog__btn.danger {
color: #ef4444;
border-color: color-mix(in srgb, #ef4444 45%, var(--theme-control-border-strong));
}
.workspace-manage-list {
display: grid;
align-content: start;
gap: 8px;
min-height: 0;
max-height: 100%;
overflow: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.workspace-manage-list::-webkit-scrollbar {
display: none;
}
.workspace-manage-row {
display: grid;
grid-template-columns: minmax(0, 1fr) auto;
gap: 10px;
padding: 10px;
border: 1px solid var(--theme-control-border);
border-radius: 12px;
background: var(--theme-surface-soft);
}
.workspace-manage-row.current {
border-color: color-mix(in srgb, var(--claude-accent) 45%, var(--theme-control-border));
box-shadow: 0 0 0 1px color-mix(in srgb, var(--claude-accent) 18%, transparent);
}
.workspace-manage-main {
display: grid;
gap: 6px;
min-width: 0;
}
.workspace-manage-name {
width: 100%;
}
.workspace-manage-actions,
.workspace-manage-badges {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
}
.workspace-manage-badge {
font-size: 11px;
color: var(--claude-text-secondary);
border: 1px solid var(--theme-control-border);
border-radius: 999px;
padding: 2px 7px;
}
.workspace-manage-badge.running {
color: var(--claude-accent);
}
.workspace-manage-empty {
font-size: 13px;
color: var(--claude-text-secondary);
}
.host-workspace-dialog__error {
font-size: 13px;
color: #ef4444;
}
@media (max-width: 640px) {
.workspace-manage-row {
grid-template-columns: 1fr;
}
}
</style>

View File

@ -60,7 +60,7 @@
type="button"
:class="{ active: panelMode === 'files' }"
@click.stop="$emit('select-panel', 'files')"
title="项目文件"
:title="workspaceKind === 'project' ? '项目' : '项目文件'"
>
<span class="icon icon-md" :style="iconStyle('folder')" aria-hidden="true"></span>
</button>
@ -107,9 +107,9 @@
管理
</button>
<h3>
<span v-if="panelMode === 'files'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
<span>项目文件</span>
<span v-if="panelMode === 'files'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
<span>{{ workspaceKind === 'project' ? '项目' : '项目文件' }}</span>
</span>
<span v-else-if="panelMode === 'todo'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('stickyNote')" aria-hidden="true"></span>
@ -200,12 +200,14 @@
type="button"
class="host-workspace-add-btn"
:disabled="hostWorkspaceSwitching"
@click="handleCreateHostWorkspace"
title="新建工作区"
@click="handleManageHostWorkspace"
:title="workspaceKind === 'project' ? '管理项目' : '管理工作区'"
>
+
<span class="icon icon-sm" :style="iconStyle('settings')" aria-hidden="true"></span>
</button>
<div class="host-workspace-title">切换工作区</div>
<div class="host-workspace-title">
{{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }}
</div>
</div>
<div class="host-workspace-list" v-if="hostWorkspaceOptions.length">
<button
@ -224,7 +226,7 @@
v-if="item.workspace_id === hostWorkspaceDefaultId"
class="host-workspace-badge default"
>
默认工作区
{{ workspaceKind === 'project' ? '默认项目' : '默认工作区' }}
</span>
<span
v-if="item.workspace_id === hostWorkspaceId"
@ -232,14 +234,22 @@
>
当前
</span>
<span
v-if="Number(item.running_task_count || 0) > 0"
class="host-workspace-badge running"
>
运行中
</span>
</span>
</div>
<div class="host-workspace-path">
{{ item.path || '(未配置路径)' }}
<div v-if="workspaceKind !== 'project'" class="host-workspace-path">
{{ item.path || (workspaceKind === 'project' ? '项目文件' : '(未配置路径)') }}
</div>
</button>
</div>
<div v-else class="file-tree-empty">暂无工作区点击右侧 + 新建</div>
<div v-else class="file-tree-empty">
{{ workspaceKind === 'project' ? '暂无项目,点击齿轮管理' : '暂无工作区,点击齿轮管理' }}
</div>
<div class="host-workspace-hint" v-if="hostWorkspaceSwitching">
正在切换请稍候
</div>
@ -292,10 +302,12 @@ const props = defineProps<{
label: string;
path?: string;
is_current?: boolean;
running_task_count?: number;
}>;
hostWorkspaceId?: string;
hostWorkspaceDefaultId?: string;
hostWorkspaceSwitching?: boolean;
workspaceKind?: 'workspace' | 'project';
}>();
const emits = defineEmits<{
@ -305,10 +317,12 @@ const emits = defineEmits<{
(event: 'toggle-thinking-mode'): void;
(event: 'switch-host-workspace', workspaceId: string): void;
(event: 'create-host-workspace'): void;
(event: 'manage-host-workspace'): void;
}>();
const panelMenuWrapper = ref<HTMLElement | null>(null);
const statusLogoSvg = statusLogoSvgRaw.replace(/fill="#000000"/g, 'fill="currentColor"');
const workspaceKind = computed(() => props.workspaceKind || 'workspace');
const panelStyle = computed(() => {
const px = `${props.width}px`;
const layoutWidth = props.collapsed ? '0px' : px;
@ -389,6 +403,13 @@ const handleCreateHostWorkspace = () => {
emits('create-host-workspace');
};
const handleManageHostWorkspace = () => {
if (props.hostWorkspaceSwitching) {
return;
}
emits('manage-host-workspace');
};
const openSubAgent = (agent: any) => {
subAgentStore.openSubAgent(agent);
};

View File

@ -104,13 +104,37 @@
</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"
v-if="loading && !displayConversations.length && !searchActive && !isDeletingConversation"
class="loading-conversations"
>
正在加载...
</div>
<div v-else-if="!displayConversations.length && !loading" class="no-conversations">
<div v-else-if="!displayConversations.length && !loading && !isDeletingConversation" class="no-conversations">
{{ searchActive ? '未找到匹配对话' : '暂无对话记录' }}
</div>
<transition-group
@ -126,9 +150,11 @@
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'
'duplicate-source-mask': conversationInsertAnimations[conv.id] === 'duplicateSource',
deleting: pendingDeletingConversationIds.includes(conv.id)
}"
@click="
openActionMenuId = null;
@ -141,7 +167,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 +191,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 +292,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 +311,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;
@ -294,6 +339,7 @@ const {
conversations,
conversationInsertAnimations,
conversationListAnimationMode,
pendingDeletingConversationIds,
conversationsLoading,
hasMoreConversations: hasMore,
loadingMoreConversations: loadingMore,
@ -331,4 +377,43 @@ const displayConversations = computed(() =>
const loading = computed(() =>
searchActive.value ? searchInProgress.value : conversationsLoading.value
);
const isDeletingConversation = computed(() => conversationListAnimationMode.value === 'delete');
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

@ -13,6 +13,7 @@ interface ConversationState {
searchResults: ConversationSummary[];
conversationInsertAnimations: Record<string, 'create' | 'duplicate' | 'duplicateSource'>;
conversationListAnimationMode: 'idle' | 'create' | 'duplicate' | 'delete';
pendingDeletingConversationIds: string[];
conversationsLoading: boolean;
hasMoreConversations: boolean;
loadingMoreConversations: boolean;
@ -27,6 +28,8 @@ interface ConversationState {
searchTotal: number;
conversationsOffset: number;
conversationsLimit: number;
runningWorkspaceTasks: any[];
acknowledgedCompletedTaskIds: string[];
}
export const useConversationStore = defineStore('conversation', {
@ -35,6 +38,7 @@ export const useConversationStore = defineStore('conversation', {
searchResults: [],
conversationInsertAnimations: {},
conversationListAnimationMode: 'idle',
pendingDeletingConversationIds: [],
conversationsLoading: false,
hasMoreConversations: false,
loadingMoreConversations: false,
@ -48,7 +52,9 @@ export const useConversationStore = defineStore('conversation', {
searchOffset: 0,
searchTotal: 0,
conversationsOffset: 0,
conversationsLimit: 20
conversationsLimit: 20,
runningWorkspaceTasks: [],
acknowledgedCompletedTaskIds: []
}),
actions: {
resetConversations() {
@ -56,6 +62,7 @@ export const useConversationStore = defineStore('conversation', {
this.searchResults = [];
this.conversationInsertAnimations = {};
this.conversationListAnimationMode = 'idle';
this.pendingDeletingConversationIds = [];
this.searchActive = false;
this.searchInProgress = false;
this.searchMoreAvailable = false;
@ -64,6 +71,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;
@ -319,6 +394,15 @@ body[data-theme='dark']
opacity: 0;
}
.conversation-sidebar .conversation-item.deleting {
transform: translateX(-112%);
opacity: 0;
pointer-events: none;
transition:
transform 420ms cubic-bezier(0.32, 0, 0.67, 0),
opacity 260ms ease;
}
.conversation-sidebar .conversation-item:hover,
.conversation-sidebar .conversation-item:focus-within,
.conversation-sidebar .conversation-item.active:hover,
@ -365,13 +449,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;

View File

@ -2290,6 +2290,7 @@ class ContextManager:
if not template:
template = (
"## 工作区信息\n"
"- **项目名称**{project_name}\n"
"- **运行环境**{runtime_environment}\n"
"- **资源限制**{resource_limit}\n"
"- **当前时间**{current_time}\n"
@ -2311,8 +2312,14 @@ class ContextManager:
now = datetime.now()
current_time_text = f"{now.year}{now.month}{now.day}{now.hour}24小时制"
project_name = (
str(getattr(self, "workspace_label", "") or "").strip()
or str(getattr(self, "project_label", "") or "").strip()
or self.project_path.name
)
content = template.format(
project_name=project_name,
runtime_environment=runtime_environment,
resource_limit=resource_limit,
container_path=self.container_mount_path or "/workspace",