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
This commit is contained in:
JOJO 2026-05-29 19:49:48 +08:00
parent 03cb39924a
commit 08d9812f45
20 changed files with 1536 additions and 110 deletions

View File

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

View File

@ -240,7 +240,7 @@ def create_host_workspace(
workspace = { workspace = {
"workspace_id": workspace_id, "workspace_id": workspace_id,
"label": clean_label or workspace_id, "label": clean_label or normalized_path.name or workspace_id,
"path": target_path_str, "path": target_path_str,
} }
raw_workspaces.append(workspace) 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__ = [ __all__ = [
"load_host_workspace_catalog", "load_host_workspace_catalog",
"resolve_host_workspace", "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() data_path = Path(data_dir).expanduser().resolve()
if data_path.name == "data" and data_path.parent.parent.name == "workspaces": if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
return (data_path.parent.parent.parent / "agentskills").resolve() 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() return (data_path.parent / "agentskills").resolve()
except Exception: except Exception:
return None return None

View File

@ -2,6 +2,7 @@
import json import json
import re import re
import shutil
from dataclasses import dataclass from dataclasses import dataclass
from datetime import datetime from datetime import datetime
from pathlib import Path from pathlib import Path
@ -16,8 +17,11 @@ from config import (
USER_SPACE_DIR, USER_SPACE_DIR,
USERS_DB_FILE, USERS_DB_FILE,
UPLOAD_QUARANTINE_SUBDIR, UPLOAD_QUARANTINE_SUBDIR,
MAIN_MEMORY_FILE,
TASK_MEMORY_FILE,
) )
from modules.personalization_manager import ensure_personalization_config from modules.personalization_manager import ensure_personalization_config
from modules.personalization_manager import PERSONALIZATION_FILENAME
@dataclass @dataclass
@ -46,6 +50,7 @@ class UserManager:
"""Handle user registration, authentication and workspace provisioning.""" """Handle user registration, authentication and workspace provisioning."""
USERNAME_REGEX = re.compile(r"^[a-z0-9_\-]{3,32}$") USERNAME_REGEX = re.compile(r"^[a-z0-9_\-]{3,32}$")
RESERVED_WORKSPACE_IDS = {"default", "personal", "shared"}
def __init__( def __init__(
self, self,
@ -119,12 +124,221 @@ class UserManager:
def get_user(self, username: str) -> Optional[UserRecord]: def get_user(self, username: str) -> Optional[UserRecord]:
return self._users.get((username or "").strip().lower()) return self._users.get((username or "").strip().lower())
def ensure_user_workspace(self, username: str) -> UserWorkspace: @staticmethod
username = self._normalize_username(username) 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() root = (self.workspace_root / username).resolve()
project_path = root / "project" projects_root = self._project_container_root(username)
data_dir = root / "data" personal_root = self._personal_root(username)
logs_dir = root / "logs" 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" uploads_dir = project_path / ".agents" / "user_upload"
skills_dir = project_path / ".agents" / "skills" skills_dir = project_path / ".agents" / "skills"
@ -134,23 +348,152 @@ class UserManager:
# 初始化数据子目录 # 初始化数据子目录
(data_dir / "conversations").mkdir(parents=True, exist_ok=True) (data_dir / "conversations").mkdir(parents=True, exist_ok=True)
(data_dir / "backups").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() quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
if not quarantine_root.is_absolute(): if not quarantine_root.is_absolute():
quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve() 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) quarantine_dir.mkdir(parents=True, exist_ok=True)
return UserWorkspace( workspace = UserWorkspace(
username=username, username=username,
root=root, root=workspace_root,
project_path=project_path, project_path=project_path,
data_dir=data_dir, data_dir=data_dir,
logs_dir=logs_dir, logs_dir=logs_dir,
uploads_dir=uploads_dir, uploads_dir=uploads_dir,
quarantine_dir=quarantine_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): def list_invite_codes(self):
return list(self._invites.values()) return list(self._invites.values())

View File

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

View File

@ -182,6 +182,7 @@ def login():
session['username'] = record.username session['username'] = record.username
session['role'] = record.role or 'user' session['role'] = record.role or 'user'
session['host_mode'] = False session['host_mode'] = False
session['workspace_id'] = 'default'
default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False) default_thinking = current_app.config.get('DEFAULT_THINKING_MODE', False)
session['thinking_mode'] = default_thinking session['thinking_mode'] = default_thinking
session['run_mode'] = current_app.config.get('DEFAULT_RUN_MODE', "deep" if default_thinking else "fast") 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) _issue_login_nonce(record.username)
clear_failures("login", identifier=client_ip) clear_failures("login", identifier=client_ip)
try: 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: except RuntimeError as exc:
session.clear() session.clear()
return jsonify({"success": False, "error": str(exc), "code": "resource_busy"}), 503 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() user = get_current_user_record()
if not user: if not user:
return redirect('/login') 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() uploads_dir = workspace.uploads_dir.resolve()
target = (uploads_dir / filename).resolve() target = (uploads_dir / filename).resolve()
try: try:
@ -480,7 +489,10 @@ def serve_workspace_file(filename: str):
user = get_current_user_record() user = get_current_user_record()
if not user: if not user:
return redirect('/login') 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() project_root = workspace.project_path.resolve()
target = (project_root / filename).resolve() target = (project_root / filename).resolve()
try: 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 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: def _ensure_workspace_skills_synced(terminal: WebTerminal, workspace) -> None:
""" """
确保工作区 skills 已按当前个性化配置完成同步 确保工作区 skills 已按当前个性化配置完成同步
@ -242,6 +255,10 @@ def get_user_resources(username: Optional[str] = None, workspace_id: Optional[st
if has_request_context(): if has_request_context():
session['workspace_id'] = getattr(workspace, "workspace_id", None) session['workspace_id'] = getattr(workspace, "workspace_id", None)
session['host_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: 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) workspace = state.api_user_manager.ensure_workspace(username, workspace_id)
else: else:
record = get_current_user_record() 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 属性 # 为兼容后续逻辑,补充 workspace_id 属性
if not hasattr(workspace, "workspace_id"): if not hasattr(workspace, "workspace_id"):
try: try:
workspace.workspace_id = "default" workspace.workspace_id = selected_workspace_id or "default"
except Exception: except Exception:
pass 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") 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) usage_tracker = None if is_api_user else get_or_create_usage_tracker(username, workspace)
terminal = state.user_terminals.get(term_key) 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['run_mode'] = terminal.run_mode
session['thinking_mode'] = terminal.thinking_mode session['thinking_mode'] = terminal.thinking_mode
session['model_key'] = getattr(terminal, "model_key", None) 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: else:
terminal.update_container_session(container_handle) terminal.update_container_session(container_handle)
attach_user_broadcast(terminal, username) attach_user_broadcast(terminal, username)
terminal.username = username terminal.username = username
terminal.user_role = "api" if is_api_user else get_current_user_role(record) 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 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) 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: if not is_api_user:
try: try:

View File

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

View File

@ -17,7 +17,9 @@ from .state import (
from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE from config import AGENT_VERSION, TERMINAL_SANDBOX_MODE
from modules.host_workspace_manager import ( from modules.host_workspace_manager import (
create_host_workspace, create_host_workspace,
delete_host_workspace,
load_host_workspace_catalog, load_host_workspace_catalog,
rename_host_workspace,
resolve_host_workspace, resolve_host_workspace,
) )
from utils.host_workspace_debug import write_host_workspace_debug 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) 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]: def _parse_android_version_from_gradle(project_root: Path) -> tuple[int, str]:
"""从 android-webview-app/app/build.gradle.kts 读取 versionCode/versionName。""" """从 android-webview-app/app/build.gradle.kts 读取 versionCode/versionName。"""
gradle_file = project_root / "android-webview-app" / "app" / "build.gradle.kts" 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" 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') @status_bp.route('/api/health')
@api_login_required @api_login_required
def get_health(): def get_health():
@ -149,10 +208,11 @@ def get_status(terminal, workspace, username):
try: try:
# 首屏状态只需要容器是否存在/运行等轻量信息Docker stats 很慢, # 首屏状态只需要容器是否存在/运行等轻量信息Docker stats 很慢,
# 由 /api/container-status 在用量面板需要时单独拉取。 # 由 /api/container-status 在用量面板需要时单独拉取。
workspace_id = getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'
container_key = ( 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" 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) status['container'] = container_manager.get_container_status(container_key, include_stats=False)
except Exception as exc: except Exception as exc:
@ -199,26 +259,7 @@ def list_host_workspaces():
session['host_workspace_id'] = current_id session['host_workspace_id'] = current_id
session['workspace_id'] = current_id session['workspace_id'] = current_id
running_by_workspace = {} workspaces = _build_host_workspaces_payload(catalog, current_id)
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)),
})
return jsonify({ return jsonify({
"success": True, "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']) @status_bp.route('/api/host/workspaces/select', methods=['GET', 'POST'])
@api_login_required @api_login_required
def select_host_workspace(): 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') @status_bp.route('/api/container-status')
@api_login_required @api_login_required
@with_terminal @with_terminal
@ -404,7 +713,7 @@ def get_container_status_api(terminal, workspace, username):
container_key = ( container_key = (
f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}" f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}"
if bool(session.get("host_mode")) or username == "host" 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) status = container_manager.get_container_status(container_key)
return jsonify({"success": True, "data": status}) return jsonify({"success": True, "data": status})
@ -417,7 +726,9 @@ def get_container_status_api(terminal, workspace, username):
@with_terminal @with_terminal
def get_project_storage(terminal, workspace, username): def get_project_storage(terminal, workspace, username):
now = time.time() 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: if cache_entry and (now - cache_entry.get("ts", 0)) < PROJECT_STORAGE_CACHE_TTL_SECONDS:
return jsonify({"success": True, "data": cache_entry["data"]}) return jsonify({"success": True, "data": cache_entry["data"]})
try: 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 "未限制", "limit_label": f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制",
"usage_percent": usage_percent "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}) return jsonify({"success": True, "data": data})
except Exception as exc: except Exception as exc:
stale = PROJECT_STORAGE_CACHE.get(username) stale = PROJECT_STORAGE_CACHE.get(cache_key)
if stale: if stale:
return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200 return jsonify({"success": True, "data": stale.get("data"), "stale": True}), 200
return jsonify({"success": False, "error": str(exc)}), 500 return jsonify({"success": False, "error": str(exc)}), 500

View File

@ -747,9 +747,8 @@ def _conversation_title_for_task(rec: TaskRecord) -> Optional[str]:
if rec.username == "host": if rec.username == "host":
path = Path(DATA_DIR).expanduser().resolve() / "conversations" / rec.workspace_id / f"{conv_id}.json" path = Path(DATA_DIR).expanduser().resolve() / "conversations" / rec.workspace_id / f"{conv_id}.json"
else: else:
# 普通 Web 用户当前仍是单工作区布局Docker 项目化阶段会改为项目目录。
from . import state 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" path = Path(workspace.data_dir).expanduser().resolve() / "conversations" / f"{conv_id}.json"
if not path.exists(): if not path.exists():
return None return None
@ -768,6 +767,11 @@ def _workspace_label_for_task(rec: TaskRecord) -> Optional[str]:
for item in catalog.get("workspaces") or []: for item in catalog.get("workspaces") or []:
if item.get("workspace_id") == rec.workspace_id: if item.get("workspace_id") == rec.workspace_id:
return str(item.get("label") or 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: except Exception:
pass pass
return rec.workspace_id return rec.workspace_id

View File

@ -96,7 +96,8 @@
:panel-menu-open="panelMenuOpen" :panel-menu-open="panelMenuOpen"
:panel-mode="panelMode" :panel-mode="panelMode"
:file-manager-disabled="policyUiBlocks.block_file_manager" :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-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId" :host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId" :host-workspace-default-id="defaultHostWorkspaceId"
@ -107,6 +108,7 @@
@toggle-thinking-mode="handleQuickModeToggle" @toggle-thinking-mode="handleQuickModeToggle"
@switch-host-workspace="handleHostWorkspaceSwitch" @switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace" @create-host-workspace="handleCreateHostWorkspace"
@manage-host-workspace="openHostWorkspaceManageDialog"
/> />
<div <div
@ -473,12 +475,29 @@
:label-value="hostWorkspaceCreateLabel" :label-value="hostWorkspaceCreateLabel"
:submitting="hostWorkspaceCreateSubmitting" :submitting="hostWorkspaceCreateSubmitting"
:error-message="hostWorkspaceCreateError" :error-message="hostWorkspaceCreateError"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
@close="closeHostWorkspaceCreateDialog" @close="closeHostWorkspaceCreateDialog"
@submit="submitHostWorkspaceCreate" @submit="submitHostWorkspaceCreate"
@update:path-value="hostWorkspaceCreatePath = $event" @update:path-value="hostWorkspaceCreatePath = $event"
@update:label-value="hostWorkspaceCreateLabel = $event" @update:label-value="hostWorkspaceCreateLabel = $event"
/> />
</transition> </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" /> <TutorialOverlay v-if="tutorialStore.running" />
<NewUserTutorialPrompt <NewUserTutorialPrompt
:visible="tutorialPromptVisible" :visible="tutorialPromptVisible"
@ -720,7 +739,8 @@
:is-connected="isConnected" :is-connected="isConnected"
:panel-menu-open="panelMenuOpen" :panel-menu-open="panelMenuOpen"
:panel-mode="panelMode" :panel-mode="panelMode"
:host-workspace-enabled="versioningHostMode" :host-workspace-enabled="versioningHostMode || dockerProjectMode"
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
:host-workspaces="hostWorkspaces" :host-workspaces="hostWorkspaces"
:host-workspace-id="currentHostWorkspaceId" :host-workspace-id="currentHostWorkspaceId"
:host-workspace-default-id="defaultHostWorkspaceId" :host-workspace-default-id="defaultHostWorkspaceId"
@ -730,6 +750,7 @@
@open-file-manager="openGuiFileManager" @open-file-manager="openGuiFileManager"
@switch-host-workspace="handleHostWorkspaceSwitch" @switch-host-workspace="handleHostWorkspaceSwitch"
@create-host-workspace="handleCreateHostWorkspace" @create-host-workspace="handleCreateHostWorkspace"
@manage-host-workspace="openHostWorkspaceManageDialog"
/> />
</div> </div>
</div> </div>

View File

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

View File

@ -173,7 +173,7 @@ export const computed = {
return !!this.compressionInProgress || !!this.compressing || !!this.currentWorkspaceHasRunningTask; return !!this.compressionInProgress || !!this.compressing || !!this.currentWorkspaceHasRunningTask;
}, },
currentWorkspaceHasRunningTask() { currentWorkspaceHasRunningTask() {
if (!this.versioningHostMode || !this.currentHostWorkspaceId) { if (!(this.versioningHostMode || this.dockerProjectMode) || !this.currentHostWorkspaceId) {
return false; return false;
} }
const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []; const tasks = Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [];

View File

@ -341,7 +341,7 @@ export const taskPollingMethods = {
(task: any) => (task: any) =>
task?.status === 'running' && task?.status === 'running' &&
task?.conversation_id === this.currentConversationId && task?.conversation_id === this.currentConversationId &&
(!this.versioningHostMode || (!(this.versioningHostMode || this.dockerProjectMode) ||
!this.currentHostWorkspaceId || !this.currentHostWorkspaceId ||
task?.workspace_id === this.currentHostWorkspaceId) task?.workspace_id === this.currentHostWorkspaceId)
); );
@ -2296,7 +2296,7 @@ export const taskPollingMethods = {
return; return;
} }
if ( if (
this.versioningHostMode && (this.versioningHostMode || this.dockerProjectMode) &&
this.currentHostWorkspaceId && this.currentHostWorkspaceId &&
runningTask?.workspace_id && runningTask?.workspace_id &&
runningTask.workspace_id !== this.currentHostWorkspaceId runningTask.workspace_id !== this.currentHostWorkspaceId

View File

@ -752,23 +752,23 @@ export const uiMethods = {
}, },
async fetchHostWorkspaces() { async fetchHostWorkspaces() {
if (!this.versioningHostMode) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
this.hostWorkspaces = []; this.hostWorkspaces = [];
this.currentHostWorkspaceId = ''; this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = ''; this.defaultHostWorkspaceId = '';
return; return;
} }
try { 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(() => ({})); const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) { if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '获取宿主机工作区列表失败'); throw new Error(payload?.error || '获取项目列表失败');
} }
const data = payload.data || {}; const data = payload.data || {};
const items = Array.isArray(data.workspaces) ? data.workspaces : []; const items = Array.isArray(data.workspaces) ? data.workspaces : [];
this.hostWorkspaces = items.map((item: any) => ({ this.hostWorkspaces = items.map((item: any) => ({
workspace_id: String(item.workspace_id || ''), workspace_id: String(item.workspace_id || ''),
label: String(item.label || item.workspace_id || '未命名工作区'), label: String(item.label || item.workspace_id || '未命名项目'),
path: String(item.path || ''), path: String(item.path || ''),
is_current: !!item.is_current, is_current: !!item.is_current,
running_task_count: Number(item.running_task_count || 0) running_task_count: Number(item.running_task_count || 0)
@ -777,7 +777,7 @@ export const uiMethods = {
this.defaultHostWorkspaceId = String(data.default_workspace_id || ''); this.defaultHostWorkspaceId = String(data.default_workspace_id || '');
await this.refreshRunningWorkspaceTasks(); await this.refreshRunningWorkspaceTasks();
} catch (error) { } catch (error) {
console.warn('加载宿主机工作区失败:', error); console.warn('加载工作区/项目失败:', error);
this.hostWorkspaces = []; this.hostWorkspaces = [];
this.currentHostWorkspaceId = ''; this.currentHostWorkspaceId = '';
this.defaultHostWorkspaceId = ''; this.defaultHostWorkspaceId = '';
@ -786,7 +786,7 @@ export const uiMethods = {
}, },
async refreshRunningWorkspaceTasks() { async refreshRunningWorkspaceTasks() {
if (!this.versioningHostMode) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
this.runningWorkspaceTasks = []; this.runningWorkspaceTasks = [];
if (this.runningWorkspaceTasksRefreshTimer) { if (this.runningWorkspaceTasksRefreshTimer) {
clearTimeout(this.runningWorkspaceTasksRefreshTimer); clearTimeout(this.runningWorkspaceTasksRefreshTimer);
@ -961,7 +961,7 @@ export const uiMethods = {
if (!workspaceId || !conversationId) { if (!workspaceId || !conversationId) {
return; return;
} }
if (this.versioningHostMode && workspaceId !== this.currentHostWorkspaceId) { if ((this.versioningHostMode || this.dockerProjectMode) && workspaceId !== this.currentHostWorkspaceId) {
await this.handleHostWorkspaceSwitch(workspaceId); await this.handleHostWorkspaceSwitch(workspaceId);
} }
if (this.currentConversationId === conversationId) { if (this.currentConversationId === conversationId) {
@ -983,7 +983,7 @@ export const uiMethods = {
if (!targetId || this.hostWorkspaceSwitching) { if (!targetId || this.hostWorkspaceSwitching) {
return; return;
} }
if (!this.versioningHostMode) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
return; return;
} }
if (targetId === this.currentHostWorkspaceId) { if (targetId === this.currentHostWorkspaceId) {
@ -991,6 +991,14 @@ export const uiMethods = {
} }
this.hostWorkspaceSwitching = true; 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 { try {
// 先在“当前工作区作用域”落盘草稿,再切换工作区。 // 先在“当前工作区作用域”落盘草稿,再切换工作区。
// 否则会把旧工作区草稿误写到目标工作区作用域里。 // 否则会把旧工作区草稿误写到目标工作区作用域里。
@ -1001,10 +1009,11 @@ export const uiMethods = {
}).catch(() => {}); }).catch(() => {});
const query = encodeURIComponent(targetId); 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(() => ({})); const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) { if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '切换宿主机工作区失败'); throw new Error(payload?.error || (this.versioningHostMode ? '切换宿主机工作区失败' : '切换项目失败'));
} }
const data = payload.data || {}; const data = payload.data || {};
this.currentHostWorkspaceId = String(data.current_workspace_id || targetId); this.currentHostWorkspaceId = String(data.current_workspace_id || targetId);
@ -1025,7 +1034,6 @@ export const uiMethods = {
taskStore.clearTask(); taskStore.clearTask();
} catch (_) {} } catch (_) {}
this.clearLocalTaskUiState?.('switch-host-workspace'); this.clearLocalTaskUiState?.('switch-host-workspace');
this.conversationsOffset = 0;
await this.fetchHostWorkspaces(); await this.fetchHostWorkspaces();
const activeStatuses = new Set(['pending', 'running', 'cancel_requested']); const activeStatuses = new Set(['pending', 'running', 'cancel_requested']);
const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : []) const activeTask = (Array.isArray(this.runningWorkspaceTasks) ? this.runningWorkspaceTasks : [])
@ -1040,15 +1048,23 @@ export const uiMethods = {
history.replaceState({}, '', '/new'); history.replaceState({}, '', '/new');
await this.loadConversationsList(); 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({ this.uiPushToast({
title: '工作区已切换', title: this.versioningHostMode ? '工作区已切换' : '项目已切换',
message: data.project_path || targetId, message: this.versioningHostMode
? (data.project_path || switchedLabel || targetId)
: (switchedLabel || targetId),
type: 'success' type: 'success'
}); });
} catch (error) { } catch (error) {
this.conversations = previousConversationList;
this.searchActive = previousSearchActive;
this.conversationsLoading = false;
const message = error instanceof Error ? error.message : String(error || '切换失败'); const message = error instanceof Error ? error.message : String(error || '切换失败');
this.uiPushToast({ this.uiPushToast({
title: '切换工作区失败', title: this.versioningHostMode ? '切换工作区失败' : '切换项目失败',
message, message,
type: 'error' type: 'error'
}); });
@ -1058,7 +1074,7 @@ export const uiMethods = {
}, },
async handleCreateHostWorkspace() { async handleCreateHostWorkspace() {
if (!this.versioningHostMode) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
return; return;
} }
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
@ -1070,6 +1086,23 @@ export const uiMethods = {
this.hostWorkspaceCreateDialogOpen = true; 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() { closeHostWorkspaceCreateDialog() {
if (this.hostWorkspaceCreateSubmitting) { if (this.hostWorkspaceCreateSubmitting) {
return; return;
@ -1079,34 +1112,38 @@ export const uiMethods = {
}, },
async submitHostWorkspaceCreate() { async submitHostWorkspaceCreate() {
if (!this.versioningHostMode) { if (!(this.versioningHostMode || this.dockerProjectMode)) {
return; return;
} }
if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) { if (this.hostWorkspaceSwitching || this.hostWorkspaceCreateSubmitting) {
return; return;
} }
const workspacePath = String(this.hostWorkspaceCreatePath || '').trim(); const workspacePath = String(this.hostWorkspaceCreatePath || '').trim();
if (!workspacePath) { const label = String(this.hostWorkspaceCreateLabel || '').trim();
if (this.versioningHostMode && !workspacePath) {
this.hostWorkspaceCreateError = '路径不能为空'; this.hostWorkspaceCreateError = '路径不能为空';
return; return;
} }
const label = String(this.hostWorkspaceCreateLabel || '').trim(); if (this.dockerProjectMode && !label) {
this.hostWorkspaceCreateError = '项目名称不能为空';
return;
}
this.hostWorkspaceCreateSubmitting = true; this.hostWorkspaceCreateSubmitting = true;
this.hostWorkspaceCreateError = ''; this.hostWorkspaceCreateError = '';
try { try {
const resp = await fetch('/api/host/workspaces/create', { const resp = await fetch(this.versioningHostMode ? '/api/host/workspaces/create' : '/api/projects/create', {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin', credentials: 'same-origin',
body: JSON.stringify({ body: JSON.stringify({
path: workspacePath, path: this.versioningHostMode ? workspacePath : undefined,
label label
}) })
}); });
const payload = await resp.json().catch(() => ({})); const payload = await resp.json().catch(() => ({}));
if (!resp.ok || !payload?.success) { if (!resp.ok || !payload?.success) {
throw new Error(payload?.error || '新建宿主机工作区失败'); throw new Error(payload?.error || (this.versioningHostMode ? '新建宿主机工作区失败' : '新建项目失败'));
} }
await this.fetchHostWorkspaces(); await this.fetchHostWorkspaces();
const createdLabel = payload?.data?.workspace?.label || label || workspacePath; const createdLabel = payload?.data?.workspace?.label || label || workspacePath;
@ -1114,7 +1151,7 @@ export const uiMethods = {
this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = ''; this.hostWorkspaceCreateLabel = '';
this.uiPushToast({ this.uiPushToast({
title: '工作区已创建', title: this.versioningHostMode ? '工作区已创建' : '项目已创建',
message: createdLabel, message: createdLabel,
type: 'success' type: 'success'
}); });
@ -1122,7 +1159,7 @@ export const uiMethods = {
const message = error instanceof Error ? error.message : String(error || '新建失败'); const message = error instanceof Error ? error.message : String(error || '新建失败');
this.hostWorkspaceCreateError = message; this.hostWorkspaceCreateError = message;
this.uiPushToast({ this.uiPushToast({
title: '新建工作区失败', title: this.versioningHostMode ? '新建工作区失败' : '新建项目失败',
message, message,
type: 'error' 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() { fetchTodoList() {
return this.fileFetchTodoList(); return this.fileFetchTodoList();
}, },
@ -2581,7 +2741,7 @@ export const uiMethods = {
(task: any) => (task: any) =>
task?.status === 'running' && task?.status === 'running' &&
task?.conversation_id && task?.conversation_id &&
(!this.versioningHostMode || (!(this.versioningHostMode || this.dockerProjectMode) ||
!this.currentHostWorkspaceId || !this.currentHostWorkspaceId ||
task?.workspace_id === this.currentHostWorkspaceId) task?.workspace_id === this.currentHostWorkspaceId)
); );
@ -3081,19 +3241,18 @@ export const uiMethods = {
let treePromise: Promise<any> | null = null; let treePromise: Promise<any> | null = null;
const isHostMode = statusData?.container?.mode === 'host'; const isHostMode = statusData?.container?.mode === 'host';
this.versioningHostMode = !!isHostMode; this.versioningHostMode = !!isHostMode;
this.dockerProjectMode = !isHostMode;
if (isHostMode) { if (isHostMode) {
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用'); this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
await this.fetchHostWorkspaces(); await this.fetchHostWorkspaces();
} else { } else {
this.hostWorkspaces = []; this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表');
this.currentHostWorkspaceId = ''; await this.fetchHostWorkspaces();
this.defaultHostWorkspaceId = '';
this.hostWorkspaceCreateDialogOpen = false; this.hostWorkspaceCreateDialogOpen = false;
this.hostWorkspaceCreatePath = ''; this.hostWorkspaceCreatePath = '';
this.hostWorkspaceCreateLabel = ''; this.hostWorkspaceCreateLabel = '';
this.hostWorkspaceCreateError = ''; this.hostWorkspaceCreateError = '';
this.hostWorkspaceCreateSubmitting = false; this.hostWorkspaceCreateSubmitting = false;
treePromise = this.fileFetchTree();
} }
// 获取当前对话信息 // 获取当前对话信息

View File

@ -114,6 +114,7 @@ export function dataState() {
pathAuthorizationDraft: '', pathAuthorizationDraft: '',
pathAuthorizationSaving: false, pathAuthorizationSaving: false,
versioningHostMode: false, versioningHostMode: false,
dockerProjectMode: false,
hostWorkspaces: [], hostWorkspaces: [],
currentHostWorkspaceId: '', currentHostWorkspaceId: '',
defaultHostWorkspaceId: '', defaultHostWorkspaceId: '',
@ -123,6 +124,8 @@ export function dataState() {
hostWorkspaceCreateLabel: '', hostWorkspaceCreateLabel: '',
hostWorkspaceCreateSubmitting: false, hostWorkspaceCreateSubmitting: false,
hostWorkspaceCreateError: '', hostWorkspaceCreateError: '',
hostWorkspaceManageDialogOpen: false,
hostWorkspaceManageSubmitting: false,
versioningEnabled: false, versioningEnabled: false,
newConversationVersioningEnabled: false, newConversationVersioningEnabled: false,
versioningTrackingMode: 'workspace_and_conversation', versioningTrackingMode: 'workspace_and_conversation',

View File

@ -3,12 +3,14 @@
class="host-workspace-dialog-overlay" class="host-workspace-dialog-overlay"
role="dialog" role="dialog"
aria-modal="true" aria-modal="true"
aria-label="新建工作区" :aria-label="workspaceKind === 'project' ? '新建项目' : '新建工作区'"
@click.self="handleOverlayClose" @click.self="handleOverlayClose"
> >
<form class="host-workspace-dialog" @submit.prevent="$emit('submit')"> <form class="host-workspace-dialog" @submit.prevent="$emit('submit')">
<div class="host-workspace-dialog__header"> <div class="host-workspace-dialog__header">
<div class="host-workspace-dialog__title">新建工作区</div> <div class="host-workspace-dialog__title">
{{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }}
</div>
<button <button
type="button" type="button"
class="host-workspace-dialog__close" class="host-workspace-dialog__close"
@ -20,8 +22,10 @@
</div> </div>
<div class="host-workspace-dialog__body"> <div class="host-workspace-dialog__body">
<label class="host-workspace-dialog__field"> <label v-if="workspaceKind !== 'project'" class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区路径</span> <span class="host-workspace-dialog__label">
工作区路径
</span>
<input <input
:value="pathValue" :value="pathValue"
type="text" type="text"
@ -34,12 +38,14 @@
</label> </label>
<label class="host-workspace-dialog__field"> <label class="host-workspace-dialog__field">
<span class="host-workspace-dialog__label">工作区名称可选</span> <span class="host-workspace-dialog__label">
{{ workspaceKind === 'project' ? '项目名称' : '工作区名称(可选)' }}
</span>
<input <input
:value="labelValue" :value="labelValue"
type="text" type="text"
class="host-workspace-dialog__input" class="host-workspace-dialog__input"
placeholder="例如客户A项目" :placeholder="workspaceKind === 'project' ? '例如:客户 A 项目' : '例如客户A项目'"
autocomplete="off" autocomplete="off"
:disabled="submitting" :disabled="submitting"
@input="$emit('update:labelValue', ($event.target as HTMLInputElement).value)" @input="$emit('update:labelValue', ($event.target as HTMLInputElement).value)"
@ -54,7 +60,7 @@
取消 取消
</button> </button>
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="submitting"> <button type="submit" class="host-workspace-dialog__btn primary" :disabled="submitting">
{{ submitting ? '创建中...' : '创建工作区' }} {{ submitting ? '创建中...' : (workspaceKind === 'project' ? '创建项目' : '创建工作区') }}
</button> </button>
</div> </div>
</form> </form>
@ -70,6 +76,7 @@ const props = defineProps<{
labelValue: string; labelValue: string;
submitting?: boolean; submitting?: boolean;
errorMessage?: string; errorMessage?: string;
workspaceKind?: 'workspace' | 'project';
}>(); }>();
const emits = defineEmits<{ const emits = defineEmits<{
@ -79,6 +86,8 @@ const emits = defineEmits<{
(event: 'update:labelValue', value: string): void; (event: 'update:labelValue', value: string): void;
}>(); }>();
const workspaceKind = props.workspaceKind || 'workspace';
const handleOverlayClose = () => { const handleOverlayClose = () => {
if (props.submitting) return; if (props.submitting) return;
emits('close'); 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" type="button"
:class="{ active: panelMode === 'files' }" :class="{ active: panelMode === 'files' }"
@click.stop="$emit('select-panel', 'files')" @click.stop="$emit('select-panel', 'files')"
title="项目文件" :title="workspaceKind === 'project' ? '项目' : '项目文件'"
> >
<span class="icon icon-md" :style="iconStyle('folder')" aria-hidden="true"></span> <span class="icon icon-md" :style="iconStyle('folder')" aria-hidden="true"></span>
</button> </button>
@ -107,9 +107,9 @@
管理 管理
</button> </button>
<h3> <h3>
<span v-if="panelMode === 'files'" class="icon-label"> <span v-if="panelMode === 'files'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
<span>项目文件</span> <span>{{ workspaceKind === 'project' ? '项目' : '项目文件' }}</span>
</span> </span>
<span v-else-if="panelMode === 'todo'" class="icon-label"> <span v-else-if="panelMode === 'todo'" class="icon-label">
<span class="icon icon-sm" :style="iconStyle('stickyNote')" aria-hidden="true"></span> <span class="icon icon-sm" :style="iconStyle('stickyNote')" aria-hidden="true"></span>
@ -200,12 +200,14 @@
type="button" type="button"
class="host-workspace-add-btn" class="host-workspace-add-btn"
:disabled="hostWorkspaceSwitching" :disabled="hostWorkspaceSwitching"
@click="handleCreateHostWorkspace" @click="handleManageHostWorkspace"
title="新建工作区" :title="workspaceKind === 'project' ? '管理项目' : '管理工作区'"
> >
+ <span class="icon icon-sm" :style="iconStyle('settings')" aria-hidden="true"></span>
</button> </button>
<div class="host-workspace-title">切换工作区</div> <div class="host-workspace-title">
{{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }}
</div>
</div> </div>
<div class="host-workspace-list" v-if="hostWorkspaceOptions.length"> <div class="host-workspace-list" v-if="hostWorkspaceOptions.length">
<button <button
@ -224,7 +226,7 @@
v-if="item.workspace_id === hostWorkspaceDefaultId" v-if="item.workspace_id === hostWorkspaceDefaultId"
class="host-workspace-badge default" class="host-workspace-badge default"
> >
默认工作区 {{ workspaceKind === 'project' ? '默认项目' : '默认工作区' }}
</span> </span>
<span <span
v-if="item.workspace_id === hostWorkspaceId" v-if="item.workspace_id === hostWorkspaceId"
@ -240,12 +242,14 @@
</span> </span>
</span> </span>
</div> </div>
<div class="host-workspace-path"> <div v-if="workspaceKind !== 'project'" class="host-workspace-path">
{{ item.path || '(未配置路径)' }} {{ item.path || (workspaceKind === 'project' ? '项目文件' : '(未配置路径)') }}
</div> </div>
</button> </button>
</div> </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 class="host-workspace-hint" v-if="hostWorkspaceSwitching">
正在切换请稍候 正在切换请稍候
</div> </div>
@ -303,6 +307,7 @@ const props = defineProps<{
hostWorkspaceId?: string; hostWorkspaceId?: string;
hostWorkspaceDefaultId?: string; hostWorkspaceDefaultId?: string;
hostWorkspaceSwitching?: boolean; hostWorkspaceSwitching?: boolean;
workspaceKind?: 'workspace' | 'project';
}>(); }>();
const emits = defineEmits<{ const emits = defineEmits<{
@ -312,10 +317,12 @@ const emits = defineEmits<{
(event: 'toggle-thinking-mode'): void; (event: 'toggle-thinking-mode'): void;
(event: 'switch-host-workspace', workspaceId: string): void; (event: 'switch-host-workspace', workspaceId: string): void;
(event: 'create-host-workspace'): void; (event: 'create-host-workspace'): void;
(event: 'manage-host-workspace'): void;
}>(); }>();
const panelMenuWrapper = ref<HTMLElement | null>(null); const panelMenuWrapper = ref<HTMLElement | null>(null);
const statusLogoSvg = statusLogoSvgRaw.replace(/fill="#000000"/g, 'fill="currentColor"'); const statusLogoSvg = statusLogoSvgRaw.replace(/fill="#000000"/g, 'fill="currentColor"');
const workspaceKind = computed(() => props.workspaceKind || 'workspace');
const panelStyle = computed(() => { const panelStyle = computed(() => {
const px = `${props.width}px`; const px = `${props.width}px`;
const layoutWidth = props.collapsed ? '0px' : px; const layoutWidth = props.collapsed ? '0px' : px;
@ -396,6 +403,13 @@ const handleCreateHostWorkspace = () => {
emits('create-host-workspace'); emits('create-host-workspace');
}; };
const handleManageHostWorkspace = () => {
if (props.hostWorkspaceSwitching) {
return;
}
emits('manage-host-workspace');
};
const openSubAgent = (agent: any) => { const openSubAgent = (agent: any) => {
subAgentStore.openSubAgent(agent); subAgentStore.openSubAgent(agent);
}; };

View File

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