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:
parent
03cb39924a
commit
08d9812f45
@ -493,14 +493,10 @@ class MainTerminalToolsExecutionMixin:
|
||||
if self._is_host_mode():
|
||||
return Path(AGENT_SKILLS_DIR).expanduser().resolve()
|
||||
|
||||
data_dir = Path(self.data_dir).expanduser().resolve()
|
||||
# 普通 Docker 用户: users/<username>/data -> users/<username>/agentskills
|
||||
# API 工作区用户: users_api/<username>/workspaces/<ws>/data -> users_api/<username>/agentskills
|
||||
if data_dir.name == "data" and data_dir.parent.parent.name == "workspaces":
|
||||
user_root = data_dir.parent.parent.parent
|
||||
else:
|
||||
user_root = data_dir.parent
|
||||
return (user_root / "agentskills").resolve()
|
||||
inferred = infer_private_skills_dir(self.data_dir)
|
||||
if inferred:
|
||||
return inferred
|
||||
return (Path(self.data_dir).expanduser().resolve().parent / "agentskills").resolve()
|
||||
|
||||
def _handle_create_skill_tool(self, arguments: Dict[str, Any]) -> Dict[str, Any]:
|
||||
source = self._resolve_create_skill_source_dir(arguments.get("source_dir"))
|
||||
|
||||
@ -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",
|
||||
|
||||
@ -55,6 +55,41 @@ def infer_private_skills_dir(data_dir: str | Path | None) -> Optional[Path]:
|
||||
data_path = Path(data_dir).expanduser().resolve()
|
||||
if data_path.name == "data" and data_path.parent.parent.name == "workspaces":
|
||||
return (data_path.parent.parent.parent / "agentskills").resolve()
|
||||
# Docker Web 项目化布局:
|
||||
# users/<user>/projects/<project_id>/data -> users/<user>/personal/agentskills
|
||||
if data_path.name == "data" and data_path.parent.parent.name == "projects":
|
||||
user_root = data_path.parent.parent.parent
|
||||
shared_root = (user_root / "personal" / "agentskills").resolve()
|
||||
legacy_root = (data_path.parent / "agentskills").resolve()
|
||||
if legacy_root.exists() and legacy_root.is_dir() and legacy_root != shared_root:
|
||||
shared_root.mkdir(parents=True, exist_ok=True)
|
||||
for item in list(legacy_root.iterdir()):
|
||||
dest = shared_root / item.name
|
||||
if not dest.exists():
|
||||
shutil.move(str(item), str(dest))
|
||||
try:
|
||||
legacy_root.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
return shared_root
|
||||
# 兼容上一轮临时结构:
|
||||
# users/<user>/project/<project_id>/data -> users/<user>/personal/agentskills
|
||||
if data_path.name == "data" and data_path.parent.parent.name == "project":
|
||||
user_root = data_path.parent.parent.parent
|
||||
shared_root = (user_root / "personal" / "agentskills").resolve()
|
||||
temp_shared_root = (data_path.parent.parent / "shared" / "agentskills").resolve()
|
||||
for legacy_root in (temp_shared_root, data_path.parent / "agentskills"):
|
||||
if legacy_root.exists() and legacy_root.is_dir() and legacy_root != shared_root:
|
||||
shared_root.mkdir(parents=True, exist_ok=True)
|
||||
for item in list(legacy_root.iterdir()):
|
||||
dest = shared_root / item.name
|
||||
if not dest.exists():
|
||||
shutil.move(str(item), str(dest))
|
||||
try:
|
||||
legacy_root.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
return shared_root
|
||||
return (data_path.parent / "agentskills").resolve()
|
||||
except Exception:
|
||||
return None
|
||||
|
||||
@ -2,6 +2,7 @@
|
||||
|
||||
import json
|
||||
import re
|
||||
import shutil
|
||||
from dataclasses import dataclass
|
||||
from datetime import datetime
|
||||
from pathlib import Path
|
||||
@ -16,8 +17,11 @@ from config import (
|
||||
USER_SPACE_DIR,
|
||||
USERS_DB_FILE,
|
||||
UPLOAD_QUARANTINE_SUBDIR,
|
||||
MAIN_MEMORY_FILE,
|
||||
TASK_MEMORY_FILE,
|
||||
)
|
||||
from modules.personalization_manager import ensure_personalization_config
|
||||
from modules.personalization_manager import PERSONALIZATION_FILENAME
|
||||
|
||||
|
||||
@dataclass
|
||||
@ -46,6 +50,7 @@ class UserManager:
|
||||
"""Handle user registration, authentication and workspace provisioning."""
|
||||
|
||||
USERNAME_REGEX = re.compile(r"^[a-z0-9_\-]{3,32}$")
|
||||
RESERVED_WORKSPACE_IDS = {"default", "personal", "shared"}
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
@ -119,12 +124,221 @@ class UserManager:
|
||||
def get_user(self, username: str) -> Optional[UserRecord]:
|
||||
return self._users.get((username or "").strip().lower())
|
||||
|
||||
def ensure_user_workspace(self, username: str) -> UserWorkspace:
|
||||
username = self._normalize_username(username)
|
||||
@staticmethod
|
||||
def normalize_workspace_id(workspace_id: str) -> str:
|
||||
candidate = (workspace_id or "default").strip()
|
||||
if not candidate:
|
||||
candidate = "default"
|
||||
if not re.fullmatch(r"[A-Za-z0-9._-]{1,40}", candidate):
|
||||
raise ValueError("项目 ID 只能包含字母、数字、点、下划线或连字符,长度 1-40。")
|
||||
return candidate
|
||||
|
||||
@classmethod
|
||||
def validate_new_workspace_id(cls, workspace_id: str) -> str:
|
||||
candidate = cls.normalize_workspace_id(workspace_id)
|
||||
if candidate in cls.RESERVED_WORKSPACE_IDS or candidate.startswith("."):
|
||||
raise ValueError("该项目名称已被系统保留,请换一个名称。")
|
||||
return candidate
|
||||
|
||||
def _project_container_root(self, username: str) -> Path:
|
||||
return (self.workspace_root / username / "projects").resolve()
|
||||
|
||||
def _personal_root(self, username: str) -> Path:
|
||||
return (self.workspace_root / username / "personal").resolve()
|
||||
|
||||
@staticmethod
|
||||
def _looks_like_project_workspace_dir(path: Path) -> bool:
|
||||
return path.is_dir() and ((path / "project").exists() or (path / "data").exists())
|
||||
|
||||
def _move_dir_contents(self, source: Path, target: Path) -> None:
|
||||
if not source.exists() or not source.is_dir():
|
||||
return
|
||||
target.mkdir(parents=True, exist_ok=True)
|
||||
for child in list(source.iterdir()):
|
||||
dest = target / child.name
|
||||
if dest.exists():
|
||||
continue
|
||||
shutil.move(str(child), str(dest))
|
||||
|
||||
def _ensure_shared_file_link(self, link_path: Path, target_path: Path) -> None:
|
||||
target_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
link_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
if link_path.is_symlink():
|
||||
try:
|
||||
if link_path.resolve() == target_path.resolve():
|
||||
return
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
link_path.unlink()
|
||||
except Exception:
|
||||
return
|
||||
if link_path.exists():
|
||||
if not link_path.is_file():
|
||||
return
|
||||
try:
|
||||
should_adopt_existing = (
|
||||
not target_path.exists()
|
||||
or (target_path.is_file() and target_path.stat().st_size == 0)
|
||||
)
|
||||
except Exception:
|
||||
should_adopt_existing = not target_path.exists()
|
||||
if should_adopt_existing:
|
||||
try:
|
||||
shutil.copy2(str(link_path), str(target_path))
|
||||
except Exception:
|
||||
return
|
||||
try:
|
||||
link_path.unlink()
|
||||
except Exception:
|
||||
return
|
||||
if not target_path.exists():
|
||||
target_path.touch()
|
||||
try:
|
||||
link_path.symlink_to(target_path)
|
||||
except Exception:
|
||||
if not link_path.exists() and target_path.exists():
|
||||
try:
|
||||
shutil.copy2(target_path, link_path)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def _ensure_shared_user_state_links(self, username: str, data_dir: Path) -> None:
|
||||
"""项目间共享用户级个性化与记忆,同时保持 data_dir 路径兼容。"""
|
||||
personal_dir = self._personal_root(username)
|
||||
personalization_dir = personal_dir / "personalization"
|
||||
memory_dir = personal_dir / "memory"
|
||||
personalization_dir.mkdir(parents=True, exist_ok=True)
|
||||
memory_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
self._ensure_shared_file_link(
|
||||
data_dir / PERSONALIZATION_FILENAME,
|
||||
personalization_dir / PERSONALIZATION_FILENAME,
|
||||
)
|
||||
ensure_personalization_config(personalization_dir)
|
||||
self._ensure_shared_file_link(
|
||||
data_dir / Path(MAIN_MEMORY_FILE).name,
|
||||
memory_dir / Path(MAIN_MEMORY_FILE).name,
|
||||
)
|
||||
self._ensure_shared_file_link(
|
||||
data_dir / Path(TASK_MEMORY_FILE).name,
|
||||
memory_dir / Path(TASK_MEMORY_FILE).name,
|
||||
)
|
||||
|
||||
def _migrate_legacy_docker_workspace_layout(self, username: str) -> None:
|
||||
"""把旧 Docker Web 单项目目录迁移为 projects/default/{project,data,logs}。
|
||||
|
||||
旧布局:
|
||||
<user>/project/*
|
||||
<user>/data/*
|
||||
<user>/logs/*
|
||||
|
||||
新布局:
|
||||
<user>/projects/default/project/*
|
||||
<user>/projects/default/data/*
|
||||
<user>/projects/default/logs/*
|
||||
<user>/personal/{personalization,memory,agentskills}
|
||||
"""
|
||||
root = (self.workspace_root / username).resolve()
|
||||
project_path = root / "project"
|
||||
data_dir = root / "data"
|
||||
logs_dir = root / "logs"
|
||||
projects_root = self._project_container_root(username)
|
||||
personal_root = self._personal_root(username)
|
||||
legacy_project_root = root / "project"
|
||||
default_root = projects_root / "default"
|
||||
default_project = default_root / "project"
|
||||
default_data = default_root / "data"
|
||||
default_logs = default_root / "logs"
|
||||
marker = default_root / ".migrated_from_legacy"
|
||||
|
||||
# 兼容上一轮临时结构 users/<user>/project/<id> 与 users/<user>/project/shared。
|
||||
if legacy_project_root.exists() and legacy_project_root.is_dir():
|
||||
projects_root.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
legacy_shared = legacy_project_root / "shared"
|
||||
if legacy_shared.exists() and legacy_shared.is_dir():
|
||||
for sub in ("personalization", "memory", "agentskills"):
|
||||
self._move_dir_contents(legacy_shared / sub, personal_root / sub)
|
||||
try:
|
||||
shutil.rmtree(legacy_shared, ignore_errors=True)
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
for item in list(legacy_project_root.iterdir()):
|
||||
if item.name.startswith("."):
|
||||
continue
|
||||
if item.name in self.RESERVED_WORKSPACE_IDS - {"default"}:
|
||||
continue
|
||||
if self._looks_like_project_workspace_dir(item):
|
||||
dest = projects_root / item.name
|
||||
if not dest.exists():
|
||||
shutil.move(str(item), str(dest))
|
||||
|
||||
# 迁移最老布局 users/<user>/project/* 下的实际文件到 default/project。
|
||||
if legacy_project_root.exists() and not marker.exists():
|
||||
legacy_children = [
|
||||
child
|
||||
for child in list(legacy_project_root.iterdir())
|
||||
if (
|
||||
not child.name.startswith(".")
|
||||
and not (child.is_dir() and child.name in (self.RESERVED_WORKSPACE_IDS - {"project"}))
|
||||
and not self._looks_like_project_workspace_dir(child)
|
||||
)
|
||||
]
|
||||
if legacy_children:
|
||||
default_project.mkdir(parents=True, exist_ok=True)
|
||||
for child in legacy_children:
|
||||
dest = default_project / child.name
|
||||
if not dest.exists():
|
||||
shutil.move(str(child), str(dest))
|
||||
try:
|
||||
legacy_project_root.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
# 迁移旧 data/logs。
|
||||
legacy_data = root / "data"
|
||||
legacy_logs = root / "logs"
|
||||
if legacy_data.exists() and legacy_data.is_dir() and legacy_data.resolve() != default_data.resolve():
|
||||
if not default_data.exists():
|
||||
default_data.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(legacy_data), str(default_data))
|
||||
else:
|
||||
self._move_dir_contents(legacy_data, default_data)
|
||||
try:
|
||||
legacy_data.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
if legacy_logs.exists() and legacy_logs.is_dir() and legacy_logs.resolve() != default_logs.resolve():
|
||||
if not default_logs.exists():
|
||||
default_logs.parent.mkdir(parents=True, exist_ok=True)
|
||||
shutil.move(str(legacy_logs), str(default_logs))
|
||||
else:
|
||||
self._move_dir_contents(legacy_logs, default_logs)
|
||||
try:
|
||||
legacy_logs.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
legacy_agentskills = root / "agentskills"
|
||||
if legacy_agentskills.exists() and legacy_agentskills.is_dir():
|
||||
self._move_dir_contents(legacy_agentskills, personal_root / "agentskills")
|
||||
try:
|
||||
legacy_agentskills.rmdir()
|
||||
except Exception:
|
||||
pass
|
||||
try:
|
||||
default_root.mkdir(parents=True, exist_ok=True)
|
||||
marker.write_text("ok", encoding="utf-8")
|
||||
except Exception:
|
||||
pass
|
||||
|
||||
def ensure_user_workspace(self, username: str, workspace_id: str = "default") -> UserWorkspace:
|
||||
username = self._normalize_username(username)
|
||||
workspace_id = self.normalize_workspace_id(workspace_id)
|
||||
root = (self.workspace_root / username).resolve()
|
||||
self._migrate_legacy_docker_workspace_layout(username)
|
||||
workspace_root = self._project_container_root(username) / workspace_id
|
||||
project_path = workspace_root / "project"
|
||||
data_dir = workspace_root / "data"
|
||||
logs_dir = workspace_root / "logs"
|
||||
uploads_dir = project_path / ".agents" / "user_upload"
|
||||
skills_dir = project_path / ".agents" / "skills"
|
||||
|
||||
@ -134,23 +348,152 @@ class UserManager:
|
||||
# 初始化数据子目录
|
||||
(data_dir / "conversations").mkdir(parents=True, exist_ok=True)
|
||||
(data_dir / "backups").mkdir(parents=True, exist_ok=True)
|
||||
ensure_personalization_config(data_dir)
|
||||
self._ensure_shared_user_state_links(username, data_dir)
|
||||
|
||||
quarantine_root = Path(UPLOAD_QUARANTINE_SUBDIR).expanduser()
|
||||
if not quarantine_root.is_absolute():
|
||||
quarantine_root = (self.workspace_root.parent / UPLOAD_QUARANTINE_SUBDIR).resolve()
|
||||
quarantine_dir = (quarantine_root / username).resolve()
|
||||
quarantine_dir = (quarantine_root / username / workspace_id).resolve()
|
||||
quarantine_dir.mkdir(parents=True, exist_ok=True)
|
||||
|
||||
return UserWorkspace(
|
||||
workspace = UserWorkspace(
|
||||
username=username,
|
||||
root=root,
|
||||
root=workspace_root,
|
||||
project_path=project_path,
|
||||
data_dir=data_dir,
|
||||
logs_dir=logs_dir,
|
||||
uploads_dir=uploads_dir,
|
||||
quarantine_dir=quarantine_dir,
|
||||
)
|
||||
workspace.workspace_id = workspace_id
|
||||
return workspace
|
||||
|
||||
def list_user_workspaces(self, username: str) -> Dict[str, Dict]:
|
||||
username = self._normalize_username(username)
|
||||
self._migrate_legacy_docker_workspace_layout(username)
|
||||
result: Dict[str, Dict] = {}
|
||||
|
||||
projects_root = self._project_container_root(username)
|
||||
default_project = projects_root / "default" / "project"
|
||||
default_data = projects_root / "default" / "data"
|
||||
if default_project.exists() or default_data.exists():
|
||||
result["default"] = {
|
||||
"workspace_id": "default",
|
||||
"label": "默认项目",
|
||||
"path": "",
|
||||
"is_default": True,
|
||||
"has_conversations": (default_data / "conversations").exists(),
|
||||
}
|
||||
else:
|
||||
# 新用户也始终暴露默认项目,首次访问时再创建目录。
|
||||
result["default"] = {
|
||||
"workspace_id": "default",
|
||||
"label": "默认项目",
|
||||
"path": "",
|
||||
"is_default": True,
|
||||
"has_conversations": False,
|
||||
}
|
||||
|
||||
if projects_root.exists():
|
||||
for item in sorted(projects_root.iterdir(), key=lambda p: p.name.lower()):
|
||||
if not item.is_dir():
|
||||
continue
|
||||
ws_id = item.name
|
||||
if ws_id in self.RESERVED_WORKSPACE_IDS or ws_id.startswith("."):
|
||||
continue
|
||||
data_dir = item / "data"
|
||||
result[ws_id] = {
|
||||
"workspace_id": ws_id,
|
||||
"label": self._load_workspace_label(item, ws_id),
|
||||
"path": "",
|
||||
"is_default": False,
|
||||
"has_conversations": (data_dir / "conversations").exists(),
|
||||
}
|
||||
return result
|
||||
|
||||
def generate_workspace_id(self, username: str, label: str = "") -> str:
|
||||
username = self._normalize_username(username)
|
||||
raw = (label or "project").strip().lower()
|
||||
slug = re.sub(r"[^a-z0-9]+", "-", raw).strip("-")
|
||||
if not slug or slug in self.RESERVED_WORKSPACE_IDS or slug.startswith("."):
|
||||
slug = "project"
|
||||
slug = slug[:32].strip("-") or "project"
|
||||
existing = set(self.list_user_workspaces(username).keys())
|
||||
candidate = slug
|
||||
idx = 2
|
||||
while candidate in existing or candidate in self.RESERVED_WORKSPACE_IDS or candidate.startswith("."):
|
||||
suffix = f"-{idx}"
|
||||
candidate = f"{slug[: max(1, 40 - len(suffix))]}{suffix}"
|
||||
idx += 1
|
||||
return candidate
|
||||
|
||||
def create_user_workspace(self, username: str, workspace_id: str = "", label: str = "") -> UserWorkspace:
|
||||
ws_id = (
|
||||
self.validate_new_workspace_id(workspace_id)
|
||||
if workspace_id
|
||||
else self.generate_workspace_id(username, label)
|
||||
)
|
||||
workspace = self.ensure_user_workspace(username, ws_id)
|
||||
label_text = (label or "").strip()
|
||||
if label_text:
|
||||
meta_path = Path(workspace.root) / "project.json"
|
||||
try:
|
||||
meta_path.write_text(
|
||||
json.dumps({"workspace_id": ws_id, "label": label_text}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
except Exception:
|
||||
pass
|
||||
return workspace
|
||||
|
||||
def rename_user_workspace(self, username: str, workspace_id: str, label: str) -> Dict:
|
||||
username = self._normalize_username(username)
|
||||
ws_id = self.normalize_workspace_id(workspace_id)
|
||||
label_text = (label or "").strip()
|
||||
if not label_text:
|
||||
raise ValueError("项目名称不能为空")
|
||||
known = self.list_user_workspaces(username)
|
||||
if ws_id not in known:
|
||||
raise ValueError("项目不存在")
|
||||
workspace = self.ensure_user_workspace(username, ws_id)
|
||||
meta_path = Path(workspace.root) / "project.json"
|
||||
meta_path.write_text(
|
||||
json.dumps({"workspace_id": ws_id, "label": label_text}, ensure_ascii=False, indent=2),
|
||||
encoding="utf-8",
|
||||
)
|
||||
return {
|
||||
"workspace_id": ws_id,
|
||||
"label": label_text,
|
||||
"path": "",
|
||||
"is_default": ws_id == "default",
|
||||
"has_conversations": (Path(workspace.data_dir) / "conversations").exists(),
|
||||
}
|
||||
|
||||
def delete_user_workspace(self, username: str, workspace_id: str) -> None:
|
||||
username = self._normalize_username(username)
|
||||
ws_id = self.validate_new_workspace_id(workspace_id)
|
||||
projects_root = self._project_container_root(username)
|
||||
target = (projects_root / ws_id).resolve()
|
||||
try:
|
||||
target.relative_to(projects_root.resolve())
|
||||
except ValueError as exc:
|
||||
raise ValueError("项目路径不合法") from exc
|
||||
if not target.exists() or not target.is_dir():
|
||||
raise ValueError("项目不存在")
|
||||
shutil.rmtree(target)
|
||||
|
||||
@staticmethod
|
||||
def _load_workspace_label(workspace_root: Path, fallback: str) -> str:
|
||||
meta_path = workspace_root / "project.json"
|
||||
if meta_path.exists():
|
||||
try:
|
||||
data = json.loads(meta_path.read_text(encoding="utf-8"))
|
||||
label = str(data.get("label") or "").strip()
|
||||
if label:
|
||||
return label
|
||||
except Exception:
|
||||
pass
|
||||
return fallback
|
||||
|
||||
def list_invite_codes(self):
|
||||
return list(self._invites.values())
|
||||
|
||||
@ -1,5 +1,6 @@
|
||||
## 工作区信息
|
||||
|
||||
- **项目名称**:{project_name}
|
||||
- **运行环境**:{runtime_environment}
|
||||
- **资源限制**:{resource_limit}
|
||||
- **当前时间**:{current_time}
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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:
|
||||
|
||||
@ -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]:
|
||||
|
||||
363
server/status.py
363
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
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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"
|
||||
/>
|
||||
|
||||
<div
|
||||
@ -473,12 +475,29 @@
|
||||
:label-value="hostWorkspaceCreateLabel"
|
||||
:submitting="hostWorkspaceCreateSubmitting"
|
||||
:error-message="hostWorkspaceCreateError"
|
||||
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
|
||||
@close="closeHostWorkspaceCreateDialog"
|
||||
@submit="submitHostWorkspaceCreate"
|
||||
@update:path-value="hostWorkspaceCreatePath = $event"
|
||||
@update:label-value="hostWorkspaceCreateLabel = $event"
|
||||
/>
|
||||
</transition>
|
||||
<transition name="overlay-fade">
|
||||
<HostWorkspaceManageDialog
|
||||
v-if="hostWorkspaceManageDialogOpen"
|
||||
:workspaces="hostWorkspaces"
|
||||
:current-workspace-id="currentHostWorkspaceId"
|
||||
:default-workspace-id="defaultHostWorkspaceId"
|
||||
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
|
||||
:busy="hostWorkspaceCreateSubmitting || hostWorkspaceManageSubmitting"
|
||||
:create-submitting="hostWorkspaceCreateSubmitting"
|
||||
:error-message="hostWorkspaceCreateError"
|
||||
@close="closeHostWorkspaceManageDialog"
|
||||
@create="submitHostWorkspaceCreateFromManage"
|
||||
@rename="renameHostWorkspace"
|
||||
@delete="deleteHostWorkspace"
|
||||
/>
|
||||
</transition>
|
||||
<TutorialOverlay v-if="tutorialStore.running" />
|
||||
<NewUserTutorialPrompt
|
||||
:visible="tutorialPromptVisible"
|
||||
@ -720,7 +739,8 @@
|
||||
:is-connected="isConnected"
|
||||
:panel-menu-open="panelMenuOpen"
|
||||
:panel-mode="panelMode"
|
||||
:host-workspace-enabled="versioningHostMode"
|
||||
:host-workspace-enabled="versioningHostMode || dockerProjectMode"
|
||||
:workspace-kind="versioningHostMode ? 'workspace' : 'project'"
|
||||
:host-workspaces="hostWorkspaces"
|
||||
:host-workspace-id="currentHostWorkspaceId"
|
||||
:host-workspace-default-id="defaultHostWorkspaceId"
|
||||
@ -730,6 +750,7 @@
|
||||
@open-file-manager="openGuiFileManager"
|
||||
@switch-host-workspace="handleHostWorkspaceSwitch"
|
||||
@create-host-workspace="handleCreateHostWorkspace"
|
||||
@manage-host-workspace="openHostWorkspaceManageDialog"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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 : [];
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<any> | null = null;
|
||||
const isHostMode = statusData?.container?.mode === 'host';
|
||||
this.versioningHostMode = !!isHostMode;
|
||||
this.dockerProjectMode = !isHostMode;
|
||||
if (isHostMode) {
|
||||
this.fileMarkTreeUnavailable('宿主机模式下文件树不可用');
|
||||
await this.fetchHostWorkspaces();
|
||||
} else {
|
||||
this.hostWorkspaces = [];
|
||||
this.currentHostWorkspaceId = '';
|
||||
this.defaultHostWorkspaceId = '';
|
||||
this.fileMarkTreeUnavailable('Docker 模式下文件区已改为项目列表');
|
||||
await this.fetchHostWorkspaces();
|
||||
this.hostWorkspaceCreateDialogOpen = false;
|
||||
this.hostWorkspaceCreatePath = '';
|
||||
this.hostWorkspaceCreateLabel = '';
|
||||
this.hostWorkspaceCreateError = '';
|
||||
this.hostWorkspaceCreateSubmitting = false;
|
||||
treePromise = this.fileFetchTree();
|
||||
}
|
||||
|
||||
// 获取当前对话信息
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -3,12 +3,14 @@
|
||||
class="host-workspace-dialog-overlay"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-label="新建工作区"
|
||||
:aria-label="workspaceKind === 'project' ? '新建项目' : '新建工作区'"
|
||||
@click.self="handleOverlayClose"
|
||||
>
|
||||
<form class="host-workspace-dialog" @submit.prevent="$emit('submit')">
|
||||
<div class="host-workspace-dialog__header">
|
||||
<div class="host-workspace-dialog__title">新建工作区</div>
|
||||
<div class="host-workspace-dialog__title">
|
||||
{{ workspaceKind === 'project' ? '新建项目' : '新建工作区' }}
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="host-workspace-dialog__close"
|
||||
@ -20,8 +22,10 @@
|
||||
</div>
|
||||
|
||||
<div class="host-workspace-dialog__body">
|
||||
<label class="host-workspace-dialog__field">
|
||||
<span class="host-workspace-dialog__label">工作区路径</span>
|
||||
<label v-if="workspaceKind !== 'project'" class="host-workspace-dialog__field">
|
||||
<span class="host-workspace-dialog__label">
|
||||
工作区路径
|
||||
</span>
|
||||
<input
|
||||
:value="pathValue"
|
||||
type="text"
|
||||
@ -34,12 +38,14 @@
|
||||
</label>
|
||||
|
||||
<label class="host-workspace-dialog__field">
|
||||
<span class="host-workspace-dialog__label">工作区名称(可选)</span>
|
||||
<span class="host-workspace-dialog__label">
|
||||
{{ workspaceKind === 'project' ? '项目名称' : '工作区名称(可选)' }}
|
||||
</span>
|
||||
<input
|
||||
:value="labelValue"
|
||||
type="text"
|
||||
class="host-workspace-dialog__input"
|
||||
placeholder="例如:客户A项目"
|
||||
:placeholder="workspaceKind === 'project' ? '例如:客户 A 项目' : '例如:客户A项目'"
|
||||
autocomplete="off"
|
||||
:disabled="submitting"
|
||||
@input="$emit('update:labelValue', ($event.target as HTMLInputElement).value)"
|
||||
@ -54,7 +60,7 @@
|
||||
取消
|
||||
</button>
|
||||
<button type="submit" class="host-workspace-dialog__btn primary" :disabled="submitting">
|
||||
{{ submitting ? '创建中...' : '创建工作区' }}
|
||||
{{ submitting ? '创建中...' : (workspaceKind === 'project' ? '创建项目' : '创建工作区') }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
@ -70,6 +76,7 @@ const props = defineProps<{
|
||||
labelValue: string;
|
||||
submitting?: boolean;
|
||||
errorMessage?: string;
|
||||
workspaceKind?: 'workspace' | 'project';
|
||||
}>();
|
||||
|
||||
const emits = defineEmits<{
|
||||
@ -79,6 +86,8 @@ const emits = defineEmits<{
|
||||
(event: 'update:labelValue', value: string): void;
|
||||
}>();
|
||||
|
||||
const workspaceKind = props.workspaceKind || 'workspace';
|
||||
|
||||
const handleOverlayClose = () => {
|
||||
if (props.submitting) return;
|
||||
emits('close');
|
||||
|
||||
392
static/src/components/overlay/HostWorkspaceManageDialog.vue
Normal file
392
static/src/components/overlay/HostWorkspaceManageDialog.vue
Normal 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>
|
||||
@ -60,7 +60,7 @@
|
||||
type="button"
|
||||
:class="{ active: panelMode === 'files' }"
|
||||
@click.stop="$emit('select-panel', 'files')"
|
||||
title="项目文件"
|
||||
:title="workspaceKind === 'project' ? '项目' : '项目文件'"
|
||||
>
|
||||
<span class="icon icon-md" :style="iconStyle('folder')" aria-hidden="true"></span>
|
||||
</button>
|
||||
@ -107,9 +107,9 @@
|
||||
管理
|
||||
</button>
|
||||
<h3>
|
||||
<span v-if="panelMode === 'files'" class="icon-label">
|
||||
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
|
||||
<span>项目文件</span>
|
||||
<span v-if="panelMode === 'files'" class="icon-label">
|
||||
<span class="icon icon-sm" :style="iconStyle('folder')" aria-hidden="true"></span>
|
||||
<span>{{ workspaceKind === 'project' ? '项目' : '项目文件' }}</span>
|
||||
</span>
|
||||
<span v-else-if="panelMode === 'todo'" class="icon-label">
|
||||
<span class="icon icon-sm" :style="iconStyle('stickyNote')" aria-hidden="true"></span>
|
||||
@ -200,12 +200,14 @@
|
||||
type="button"
|
||||
class="host-workspace-add-btn"
|
||||
:disabled="hostWorkspaceSwitching"
|
||||
@click="handleCreateHostWorkspace"
|
||||
title="新建工作区"
|
||||
@click="handleManageHostWorkspace"
|
||||
:title="workspaceKind === 'project' ? '管理项目' : '管理工作区'"
|
||||
>
|
||||
+
|
||||
<span class="icon icon-sm" :style="iconStyle('settings')" aria-hidden="true"></span>
|
||||
</button>
|
||||
<div class="host-workspace-title">切换工作区</div>
|
||||
<div class="host-workspace-title">
|
||||
{{ workspaceKind === 'project' ? '管理项目' : '管理工作区' }}
|
||||
</div>
|
||||
</div>
|
||||
<div class="host-workspace-list" v-if="hostWorkspaceOptions.length">
|
||||
<button
|
||||
@ -224,7 +226,7 @@
|
||||
v-if="item.workspace_id === hostWorkspaceDefaultId"
|
||||
class="host-workspace-badge default"
|
||||
>
|
||||
默认工作区
|
||||
{{ workspaceKind === 'project' ? '默认项目' : '默认工作区' }}
|
||||
</span>
|
||||
<span
|
||||
v-if="item.workspace_id === hostWorkspaceId"
|
||||
@ -240,12 +242,14 @@
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="host-workspace-path">
|
||||
{{ item.path || '(未配置路径)' }}
|
||||
<div v-if="workspaceKind !== 'project'" class="host-workspace-path">
|
||||
{{ item.path || (workspaceKind === 'project' ? '项目文件' : '(未配置路径)') }}
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div v-else class="file-tree-empty">暂无工作区,点击右侧 + 新建</div>
|
||||
<div v-else class="file-tree-empty">
|
||||
{{ workspaceKind === 'project' ? '暂无项目,点击齿轮管理' : '暂无工作区,点击齿轮管理' }}
|
||||
</div>
|
||||
<div class="host-workspace-hint" v-if="hostWorkspaceSwitching">
|
||||
正在切换,请稍候…
|
||||
</div>
|
||||
@ -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<HTMLElement | null>(null);
|
||||
const statusLogoSvg = statusLogoSvgRaw.replace(/fill="#000000"/g, 'fill="currentColor"');
|
||||
const workspaceKind = computed(() => props.workspaceKind || 'workspace');
|
||||
const panelStyle = computed(() => {
|
||||
const px = `${props.width}px`;
|
||||
const layoutWidth = props.collapsed ? '0px' : px;
|
||||
@ -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);
|
||||
};
|
||||
|
||||
@ -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",
|
||||
|
||||
Loading…
Reference in New Issue
Block a user