From 08d9812f458ab6a64ba5097adc41425850e2d361 Mon Sep 17 00:00:00 2001 From: JOJO <1498581755@qq.com> Date: Fri, 29 May 2026 19:49:48 +0800 Subject: [PATCH] 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//projects//{project,data,logs} and shared user state to users//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//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 --- core/main_terminal_parts/tools_execution.py | 12 +- modules/host_workspace_manager.py | 81 +++- modules/skills_manager.py | 35 ++ modules/user_manager.py | 361 +++++++++++++++- prompts/workspace_system.txt | 1 + server/auth.py | 18 +- server/context.py | 47 ++- server/conversation.py | 7 +- server/status.py | 363 ++++++++++++++-- server/tasks.py | 8 +- static/src/App.vue | 25 +- static/src/app/components.ts | 4 + static/src/app/computed.ts | 2 +- static/src/app/methods/taskPolling.ts | 4 +- static/src/app/methods/ui.ts | 215 ++++++++-- static/src/app/state.ts | 3 + .../overlay/HostWorkspaceCreateDialog.vue | 23 +- .../overlay/HostWorkspaceManageDialog.vue | 392 ++++++++++++++++++ static/src/components/panels/LeftPanel.vue | 38 +- utils/context_manager.py | 7 + 20 files changed, 1536 insertions(+), 110 deletions(-) create mode 100644 static/src/components/overlay/HostWorkspaceManageDialog.vue diff --git a/core/main_terminal_parts/tools_execution.py b/core/main_terminal_parts/tools_execution.py index 56a65f8..a614498 100644 --- a/core/main_terminal_parts/tools_execution.py +++ b/core/main_terminal_parts/tools_execution.py @@ -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//data -> users//agentskills - # API 工作区用户: users_api//workspaces//data -> users_api//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")) diff --git a/modules/host_workspace_manager.py b/modules/host_workspace_manager.py index 0ebc07f..5174925 100644 --- a/modules/host_workspace_manager.py +++ b/modules/host_workspace_manager.py @@ -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", diff --git a/modules/skills_manager.py b/modules/skills_manager.py index f52279c..9c0dae5 100644 --- a/modules/skills_manager.py +++ b/modules/skills_manager.py @@ -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//projects//data -> users//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//project//data -> users//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 diff --git a/modules/user_manager.py b/modules/user_manager.py index d6ad6f2..20f439c 100644 --- a/modules/user_manager.py +++ b/modules/user_manager.py @@ -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}。 + + 旧布局: + /project/* + /data/* + /logs/* + + 新布局: + /projects/default/project/* + /projects/default/data/* + /projects/default/logs/* + /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//project/ 与 users//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//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()) diff --git a/prompts/workspace_system.txt b/prompts/workspace_system.txt index 6c9a8da..86662fa 100644 --- a/prompts/workspace_system.txt +++ b/prompts/workspace_system.txt @@ -1,5 +1,6 @@ ## 工作区信息 +- **项目名称**:{project_name} - **运行环境**:{runtime_environment} - **资源限制**:{resource_limit} - **当前时间**:{current_time} diff --git a/server/auth.py b/server/auth.py index ec2244a..bb8af98 100644 --- a/server/auth.py +++ b/server/auth.py @@ -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 @@ -462,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: @@ -480,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: diff --git a/server/context.py b/server/context.py index 9d1f76b..294ccd1 100644 --- a/server/context.py +++ b/server/context.py @@ -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 已按当前个性化配置完成同步。 @@ -242,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: @@ -304,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) @@ -355,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: diff --git a/server/conversation.py b/server/conversation.py index 8bc3d62..56a5969 100644 --- a/server/conversation.py +++ b/server/conversation.py @@ -245,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]: diff --git a/server/status.py b/server/status.py index a65edeb..c3f656a 100644 --- a/server/status.py +++ b/server/status.py @@ -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,10 +208,11 @@ def get_status(terminal, workspace, username): try: # 首屏状态只需要容器是否存在/运行等轻量信息;Docker stats 很慢, # 由 /api/container-status 在用量面板需要时单独拉取。 + workspace_id = getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default' container_key = ( - f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" + f"host::{workspace_id}" if bool(session.get("host_mode")) or username == "host" - else username + else f"{username}::{workspace_id}" ) status['container'] = container_manager.get_container_status(container_key, include_stats=False) except Exception as exc: @@ -199,26 +259,7 @@ def list_host_workspaces(): session['host_workspace_id'] = current_id session['workspace_id'] = current_id - running_by_workspace = {} - try: - from .tasks import task_manager - for rec in task_manager.list_tasks("host"): - if rec.status in {"pending", "running", "cancel_requested"}: - ws_id = getattr(rec, "workspace_id", None) or "default" - running_by_workspace[ws_id] = running_by_workspace.get(ws_id, 0) + 1 - except Exception: - running_by_workspace = {} - - workspaces = [] - for item in catalog.get("workspaces") or []: - ws_id = item.get("workspace_id") - workspaces.append({ - "workspace_id": 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)), - }) + workspaces = _build_host_workspaces_payload(catalog, current_id) return jsonify({ "success": True, @@ -231,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(): @@ -396,6 +625,86 @@ 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 @@ -404,7 +713,7 @@ def get_container_status_api(terminal, workspace, username): container_key = ( f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" if bool(session.get("host_mode")) or username == "host" - else username + 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}) @@ -417,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: @@ -433,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 diff --git a/server/tasks.py b/server/tasks.py index 0da9a67..3b10f72 100644 --- a/server/tasks.py +++ b/server/tasks.py @@ -747,9 +747,8 @@ def _conversation_title_for_task(rec: TaskRecord) -> Optional[str]: if rec.username == "host": path = Path(DATA_DIR).expanduser().resolve() / "conversations" / rec.workspace_id / f"{conv_id}.json" else: - # 普通 Web 用户当前仍是单工作区布局;Docker 项目化阶段会改为项目目录。 from . import state - workspace = state.user_manager.ensure_user_workspace(rec.username) + 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 @@ -768,6 +767,11 @@ def _workspace_label_for_task(rec: TaskRecord) -> Optional[str]: 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 diff --git a/static/src/App.vue b/static/src/App.vue index 67378b8..ce9cfc7 100644 --- a/static/src/App.vue +++ b/static/src/App.vue @@ -96,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" @@ -107,6 +108,7 @@ @toggle-thinking-mode="handleQuickModeToggle" @switch-host-workspace="handleHostWorkspaceSwitch" @create-host-workspace="handleCreateHostWorkspace" + @manage-host-workspace="openHostWorkspaceManageDialog" />
+ + +
diff --git a/static/src/app/components.ts b/static/src/app/components.ts index 47beba4..840fd52 100644 --- a/static/src/app/components.ts +++ b/static/src/app/components.ts @@ -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 diff --git a/static/src/app/computed.ts b/static/src/app/computed.ts index fb00092..9154dae 100644 --- a/static/src/app/computed.ts +++ b/static/src/app/computed.ts @@ -173,7 +173,7 @@ export const computed = { return !!this.compressionInProgress || !!this.compressing || !!this.currentWorkspaceHasRunningTask; }, currentWorkspaceHasRunningTask() { - if (!this.versioningHostMode || !this.currentHostWorkspaceId) { + if (!(this.versioningHostMode || this.dockerProjectMode) || !this.currentHostWorkspaceId) { return false; } const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []; diff --git a/static/src/app/methods/taskPolling.ts b/static/src/app/methods/taskPolling.ts index 6977202..2f0dbe4 100644 --- a/static/src/app/methods/taskPolling.ts +++ b/static/src/app/methods/taskPolling.ts @@ -341,7 +341,7 @@ export const taskPollingMethods = { (task: any) => task?.status === 'running' && task?.conversation_id === this.currentConversationId && - (!this.versioningHostMode || + (!(this.versioningHostMode || this.dockerProjectMode) || !this.currentHostWorkspaceId || task?.workspace_id === this.currentHostWorkspaceId) ); @@ -2296,7 +2296,7 @@ export const taskPollingMethods = { return; } if ( - this.versioningHostMode && + (this.versioningHostMode || this.dockerProjectMode) && this.currentHostWorkspaceId && runningTask?.workspace_id && runningTask.workspace_id !== this.currentHostWorkspaceId diff --git a/static/src/app/methods/ui.ts b/static/src/app/methods/ui.ts index fa21e8d..b9bde01 100644 --- a/static/src/app/methods/ui.ts +++ b/static/src/app/methods/ui.ts @@ -752,23 +752,23 @@ 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, running_task_count: Number(item.running_task_count || 0) @@ -777,7 +777,7 @@ export const uiMethods = { this.defaultHostWorkspaceId = String(data.default_workspace_id || ''); await this.refreshRunningWorkspaceTasks(); } catch (error) { - console.warn('加载宿主机工作区失败:', error); + console.warn('加载工作区/项目失败:', error); this.hostWorkspaces = []; this.currentHostWorkspaceId = ''; this.defaultHostWorkspaceId = ''; @@ -786,7 +786,7 @@ export const uiMethods = { }, async refreshRunningWorkspaceTasks() { - if (!this.versioningHostMode) { + if (!(this.versioningHostMode || this.dockerProjectMode)) { this.runningWorkspaceTasks = []; if (this.runningWorkspaceTasksRefreshTimer) { clearTimeout(this.runningWorkspaceTasksRefreshTimer); @@ -961,7 +961,7 @@ export const uiMethods = { if (!workspaceId || !conversationId) { return; } - if (this.versioningHostMode && workspaceId !== this.currentHostWorkspaceId) { + if ((this.versioningHostMode || this.dockerProjectMode) && workspaceId !== this.currentHostWorkspaceId) { await this.handleHostWorkspaceSwitch(workspaceId); } if (this.currentConversationId === conversationId) { @@ -983,7 +983,7 @@ export const uiMethods = { if (!targetId || this.hostWorkspaceSwitching) { return; } - if (!this.versioningHostMode) { + if (!(this.versioningHostMode || this.dockerProjectMode)) { return; } if (targetId === this.currentHostWorkspaceId) { @@ -991,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 { // 先在“当前工作区作用域”落盘草稿,再切换工作区。 // 否则会把旧工作区草稿误写到目标工作区作用域里。 @@ -1001,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); @@ -1025,7 +1034,6 @@ export const uiMethods = { taskStore.clearTask(); } catch (_) {} this.clearLocalTaskUiState?.('switch-host-workspace'); - this.conversationsOffset = 0; await this.fetchHostWorkspaces(); const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []) @@ -1040,15 +1048,23 @@ export const uiMethods = { 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: data.project_path || targetId, + title: this.versioningHostMode ? '工作区已切换' : '项目已切换', + message: this.versioningHostMode + ? (data.project_path || switchedLabel || targetId) + : (switchedLabel || targetId), type: 'success' }); } 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' }); @@ -1058,7 +1074,7 @@ export const uiMethods = { }, async handleCreateHostWorkspace() { - if (!this.versioningHostMode) { + if (!(this.versioningHostMode || this.dockerProjectMode)) { return; } if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { @@ -1070,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; @@ -1079,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; @@ -1114,7 +1151,7 @@ export const uiMethods = { this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreateLabel = ''; this.uiPushToast({ - title: '工作区已创建', + title: this.versioningHostMode ? '工作区已创建' : '项目已创建', message: createdLabel, type: 'success' }); @@ -1122,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' }); @@ -1131,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(); }, @@ -2581,7 +2741,7 @@ export const uiMethods = { (task: any) => task?.status === 'running' && task?.conversation_id && - (!this.versioningHostMode || + (!(this.versioningHostMode || this.dockerProjectMode) || !this.currentHostWorkspaceId || task?.workspace_id === this.currentHostWorkspaceId) ); @@ -3081,19 +3241,18 @@ export const uiMethods = { let treePromise: Promise | 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(); } // 获取当前对话信息 diff --git a/static/src/app/state.ts b/static/src/app/state.ts index d6e2714..82df021 100644 --- a/static/src/app/state.ts +++ b/static/src/app/state.ts @@ -114,6 +114,7 @@ export function dataState() { pathAuthorizationDraft: '', pathAuthorizationSaving: false, versioningHostMode: false, + dockerProjectMode: false, hostWorkspaces: [], currentHostWorkspaceId: '', defaultHostWorkspaceId: '', @@ -123,6 +124,8 @@ export function dataState() { hostWorkspaceCreateLabel: '', hostWorkspaceCreateSubmitting: false, hostWorkspaceCreateError: '', + hostWorkspaceManageDialogOpen: false, + hostWorkspaceManageSubmitting: false, versioningEnabled: false, newConversationVersioningEnabled: false, versioningTrackingMode: 'workspace_and_conversation', diff --git a/static/src/components/overlay/HostWorkspaceCreateDialog.vue b/static/src/components/overlay/HostWorkspaceCreateDialog.vue index b624d72..1121e48 100644 --- a/static/src/components/overlay/HostWorkspaceCreateDialog.vue +++ b/static/src/components/overlay/HostWorkspaceCreateDialog.vue @@ -3,12 +3,14 @@ class="host-workspace-dialog-overlay" role="dialog" aria-modal="true" - aria-label="新建工作区" + :aria-label="workspaceKind === 'project' ? '新建项目' : '新建工作区'" @click.self="handleOverlayClose" >
-
新建工作区
+
+ {{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }} +
@@ -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'); diff --git a/static/src/components/overlay/HostWorkspaceManageDialog.vue b/static/src/components/overlay/HostWorkspaceManageDialog.vue new file mode 100644 index 0000000..88f0e1d --- /dev/null +++ b/static/src/components/overlay/HostWorkspaceManageDialog.vue @@ -0,0 +1,392 @@ + + + + + diff --git a/static/src/components/panels/LeftPanel.vue b/static/src/components/panels/LeftPanel.vue index fd8000a..2c45a9c 100644 --- a/static/src/components/panels/LeftPanel.vue +++ b/static/src/components/panels/LeftPanel.vue @@ -60,7 +60,7 @@ type="button" :class="{ active: panelMode === 'files' }" @click.stop="$emit('select-panel', 'files')" - title="项目文件" + :title="workspaceKind === 'project' ? '项目' : '项目文件'" > @@ -107,9 +107,9 @@ 管理

- - - 项目文件 + + + {{ workspaceKind === 'project' ? '项目' : '项目文件' }} @@ -200,12 +200,14 @@ type="button" class="host-workspace-add-btn" :disabled="hostWorkspaceSwitching" - @click="handleCreateHostWorkspace" - title="新建工作区" + @click="handleManageHostWorkspace" + :title="workspaceKind === 'project' ? '管理项目' : '管理工作区'" > - + + -
切换工作区
+
+ {{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }} +
-
- {{ item.path || '(未配置路径)' }} +
+ {{ item.path || (workspaceKind === 'project' ? '项目文件' : '(未配置路径)') }}
-
暂无工作区,点击右侧 + 新建
+
+ {{ workspaceKind === 'project' ? '暂无项目,点击齿轮管理' : '暂无工作区,点击齿轮管理' }} +
正在切换,请稍候…
@@ -303,6 +307,7 @@ const props = defineProps<{ hostWorkspaceId?: string; hostWorkspaceDefaultId?: string; hostWorkspaceSwitching?: boolean; + workspaceKind?: 'workspace' | 'project'; }>(); const emits = defineEmits<{ @@ -312,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(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; @@ -396,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); }; diff --git a/utils/context_manager.py b/utils/context_manager.py index 978f0c1..7f61ff2 100644 --- a/utils/context_manager.py +++ b/utils/context_manager.py @@ -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",