- Add set_default_host_workspace() and /api/host/workspaces/set-default - Add default_workspace_id persistence in UserManager and /api/projects/set-default - Add 'Set as default' button in HostWorkspaceManageDialog - Sync default badge in LeftPanel workspace list - Update all workspace/project APIs to return real default_workspace_id
1647 lines
66 KiB
Python
1647 lines
66 KiB
Python
from __future__ import annotations
|
||
import time
|
||
import re
|
||
import os
|
||
import json
|
||
import shutil
|
||
import subprocess
|
||
import sys
|
||
import tempfile
|
||
import plistlib
|
||
from pathlib import Path
|
||
from flask import Blueprint, jsonify, request, send_file, session
|
||
|
||
from .auth_helpers import api_login_required, resolve_admin_policy
|
||
from .context import with_terminal, attach_user_broadcast
|
||
from .state import (
|
||
PROJECT_STORAGE_CACHE,
|
||
PROJECT_STORAGE_CACHE_TTL_SECONDS,
|
||
PROJECT_MAX_STORAGE_MB,
|
||
container_manager,
|
||
user_manager,
|
||
)
|
||
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,
|
||
set_default_host_workspace,
|
||
)
|
||
from utils.host_workspace_debug import write_host_workspace_debug
|
||
from .utils_common import log_conn_diag
|
||
from . import state
|
||
|
||
status_bp = Blueprint('status', __name__)
|
||
STATUS_DIAG_VERBOSE = os.environ.get("STATUS_DIAG_VERBOSE", "0") not in {"0", "false", "False"}
|
||
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)
|
||
workspaces = user_manager.list_user_workspaces(username)
|
||
projects = []
|
||
for ws_id, item in workspaces.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": bool(item.get("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"
|
||
if not gradle_file.exists():
|
||
return 1, "1.0.0"
|
||
text = gradle_file.read_text(encoding="utf-8", errors="ignore")
|
||
vc_match = re.search(r"versionCode\s*=\s*(\d+)", text)
|
||
vn_match = re.search(r'versionName\s*=\s*"([^"]+)"', text)
|
||
version_code = int(vc_match.group(1)) if vc_match else 1
|
||
version_name = vn_match.group(1) if vn_match else "1.0.0"
|
||
return version_code, version_name
|
||
|
||
|
||
def _resolve_android_apk_path(project_root: Path) -> Path:
|
||
"""优先使用 release APK,其次 fallback 到 debug APK。"""
|
||
release_apk = project_root / "android-webview-app" / "app" / "release" / "app-release.apk"
|
||
if release_apk.exists():
|
||
return release_apk
|
||
return project_root / "android-webview-app" / "app" / "build" / "outputs" / "apk" / "debug" / "app-debug.apk"
|
||
|
||
|
||
def _load_app_changelog(project_root: Path) -> str:
|
||
"""读取 App 更新说明(优先 APP_CHANGELOG.md)。"""
|
||
candidates = [
|
||
project_root / "android-webview-app" / "APP_CHANGELOG.md",
|
||
project_root / "android-webview-app" / "CHANGELOG.md",
|
||
]
|
||
for path in candidates:
|
||
if not path.exists():
|
||
continue
|
||
text = path.read_text(encoding="utf-8", errors="ignore").strip()
|
||
if text:
|
||
return text[:4000]
|
||
return ""
|
||
|
||
|
||
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
|
||
|
||
|
||
def _run_project_git(project_path: Path, args: list[str]) -> tuple[bool, str]:
|
||
git_bin = shutil.which("git")
|
||
if not git_bin:
|
||
return False, ""
|
||
try:
|
||
proc = subprocess.run(
|
||
[git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args],
|
||
cwd=str(project_path),
|
||
text=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=2,
|
||
check=False,
|
||
)
|
||
except Exception:
|
||
return False, ""
|
||
return proc.returncode == 0, (proc.stdout or "").strip()
|
||
|
||
|
||
def _run_project_git_raw(project_path: Path, args: list[str], timeout: int = 4) -> tuple[bool, str]:
|
||
git_bin = shutil.which("git")
|
||
if not git_bin:
|
||
return False, ""
|
||
try:
|
||
proc = subprocess.run(
|
||
[git_bin, "-c", "core.quotePath=false", "-C", str(project_path), *args],
|
||
cwd=str(project_path),
|
||
text=True,
|
||
stdout=subprocess.PIPE,
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=timeout,
|
||
check=False,
|
||
)
|
||
except Exception:
|
||
return False, ""
|
||
return proc.returncode == 0, (proc.stdout or "")
|
||
|
||
|
||
def _sum_git_numstat(numstat_text: str) -> tuple[int, int]:
|
||
additions = 0
|
||
deletions = 0
|
||
for raw_line in (numstat_text or "").splitlines():
|
||
parts = raw_line.split("\t", 2)
|
||
if len(parts) < 2:
|
||
continue
|
||
if parts[0].isdigit():
|
||
additions += int(parts[0])
|
||
if parts[1].isdigit():
|
||
deletions += int(parts[1])
|
||
return additions, deletions
|
||
|
||
|
||
def _paths_from_git_numstat(numstat_text: str) -> list[str]:
|
||
paths: list[str] = []
|
||
for raw_line in (numstat_text or "").splitlines():
|
||
parts = raw_line.split("\t")
|
||
if len(parts) < 3:
|
||
continue
|
||
path = parts[-1].strip()
|
||
if path:
|
||
paths.append(path)
|
||
return paths
|
||
|
||
|
||
def _count_text_lines(text: str) -> int:
|
||
if text == "":
|
||
return 0
|
||
return len(text.splitlines())
|
||
|
||
|
||
def _git_file_old_line_count(repo_root: Path, rel_path: str) -> int:
|
||
ok, text = _run_project_git_raw(repo_root, ["show", f"HEAD:{rel_path}"], timeout=3)
|
||
if ok:
|
||
return _count_text_lines(text)
|
||
target = (repo_root / rel_path).resolve()
|
||
try:
|
||
if target.exists() and repo_root in target.parents:
|
||
return _count_text_lines(target.read_text(encoding="utf-8", errors="ignore"))
|
||
except Exception:
|
||
pass
|
||
return 0
|
||
|
||
|
||
def _git_file_lines(repo_root: Path, rel_path: str, ref: str = "HEAD") -> list[str]:
|
||
ok, text = _run_project_git_raw(repo_root, ["show", f"{ref}:{rel_path}"], timeout=3)
|
||
if ok:
|
||
return text.splitlines()
|
||
return []
|
||
|
||
|
||
def _working_file_lines(repo_root: Path, rel_path: str) -> list[str]:
|
||
target = (repo_root / rel_path).resolve()
|
||
try:
|
||
if target.exists() and repo_root in target.parents:
|
||
return target.read_text(encoding="utf-8", errors="ignore").splitlines()
|
||
except Exception:
|
||
pass
|
||
return _git_file_lines(repo_root, rel_path)
|
||
|
||
|
||
def _parse_hunk_header(header: str) -> tuple[int, int, int, int]:
|
||
match = re.match(r"@@ -(\d+)(?:,(\d+))? \+(\d+)(?:,(\d+))?", header)
|
||
if not match:
|
||
return 0, 0, 0, 0
|
||
old_start = int(match.group(1))
|
||
old_count = int(match.group(2) or "1")
|
||
new_start = int(match.group(3))
|
||
new_count = int(match.group(4) or "1")
|
||
return old_start, old_count, new_start, new_count
|
||
|
||
|
||
def _parse_git_diff(repo_root: Path, diff_text: str, fold_context_map: dict[str, dict[str, int]] | None = None) -> list[dict]:
|
||
files: list[dict] = []
|
||
current: dict | None = None
|
||
current_hunk: dict | None = None
|
||
old_line = 0
|
||
new_line = 0
|
||
previous_old_end = 0
|
||
previous_new_end = 0
|
||
|
||
def finish_hunk():
|
||
nonlocal current_hunk, current, previous_old_end, previous_new_end
|
||
if not current_hunk or not current:
|
||
current_hunk = None
|
||
return
|
||
path = current.get("path") or current.get("old_path") or ""
|
||
old_start = current_hunk.get("old_start") or 0
|
||
old_count = current_hunk.get("old_count") or 0
|
||
new_start = current_hunk.get("new_start") or 0
|
||
new_count = current_hunk.get("new_count") or 0
|
||
current_hunk["before_hidden"] = max(0, int(old_start) - int(previous_old_end) - 1)
|
||
before_fold_key = f"before:{old_start}"
|
||
current_hunk["before_fold_key"] = before_fold_key
|
||
requested_extra = int((fold_context_map or {}).get(path, {}).get(before_fold_key, 0) or 0)
|
||
if requested_extra > 0 and current_hunk["before_hidden"] > 0:
|
||
add_count = min(current_hunk["before_hidden"], requested_extra)
|
||
old_lines = _git_file_lines(repo_root, current.get("old_path") or path)
|
||
new_lines = _working_file_lines(repo_root, path)
|
||
injected = []
|
||
for idx in range(add_count):
|
||
old_no = int(old_start) - add_count + idx
|
||
new_no = int(new_start) - add_count + idx
|
||
text = ""
|
||
if 0 < new_no <= len(new_lines):
|
||
text = new_lines[new_no - 1]
|
||
elif 0 < old_no <= len(old_lines):
|
||
text = old_lines[old_no - 1]
|
||
injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text})
|
||
current_hunk["lines"] = injected + current_hunk.get("lines", [])
|
||
current_hunk["before_hidden"] -= add_count
|
||
previous_old_end = int(old_start) + int(old_count) - 1
|
||
previous_new_end = int(new_start) + int(new_count) - 1
|
||
current["hunks"].append(current_hunk)
|
||
current_hunk = None
|
||
|
||
def finish_file():
|
||
nonlocal current, previous_old_end, previous_new_end
|
||
finish_hunk()
|
||
if not current:
|
||
return
|
||
old_total = _git_file_old_line_count(repo_root, current.get("old_path") or current.get("path") or "")
|
||
current["after_hidden"] = max(0, old_total - int(previous_old_end))
|
||
current["after_fold_key"] = "after"
|
||
path = current.get("path") or current.get("old_path") or ""
|
||
requested_extra = int((fold_context_map or {}).get(path, {}).get("after", 0) or 0)
|
||
if requested_extra > 0 and current["after_hidden"] > 0 and current.get("hunks"):
|
||
add_count = min(current["after_hidden"], requested_extra)
|
||
old_lines = _git_file_lines(repo_root, current.get("old_path") or path)
|
||
new_lines = _working_file_lines(repo_root, path)
|
||
injected = []
|
||
for idx in range(add_count):
|
||
old_no = int(previous_old_end) + 1 + idx
|
||
new_no = int(previous_new_end) + 1 + idx
|
||
text = ""
|
||
if 0 < new_no <= len(new_lines):
|
||
text = new_lines[new_no - 1]
|
||
elif 0 < old_no <= len(old_lines):
|
||
text = old_lines[old_no - 1]
|
||
injected.append({"kind": "context", "old_line": old_no, "new_line": new_no, "text": text})
|
||
current["hunks"][-1]["lines"].extend(injected)
|
||
current["after_hidden"] -= add_count
|
||
files.append(current)
|
||
current = None
|
||
previous_old_end = 0
|
||
previous_new_end = 0
|
||
|
||
for raw in diff_text.splitlines():
|
||
if raw.startswith("diff --git "):
|
||
finish_file()
|
||
current = {
|
||
"path": "",
|
||
"old_path": "",
|
||
"additions": 0,
|
||
"deletions": 0,
|
||
"hunks": [],
|
||
"after_hidden": 0,
|
||
}
|
||
previous_old_end = 0
|
||
continue
|
||
if current is None:
|
||
continue
|
||
if raw.startswith("--- "):
|
||
old_path = raw[4:].strip()
|
||
current["old_path"] = "" if old_path == "/dev/null" else re.sub(r"^a/", "", old_path)
|
||
continue
|
||
if raw.startswith("+++ "):
|
||
new_path = raw[4:].strip()
|
||
current["path"] = current["old_path"] if new_path == "/dev/null" else re.sub(r"^b/", "", new_path)
|
||
continue
|
||
if raw.startswith("@@ "):
|
||
finish_hunk()
|
||
old_start, old_count, new_start, new_count = _parse_hunk_header(raw)
|
||
current_hunk = {
|
||
"header": raw,
|
||
"old_start": old_start,
|
||
"old_count": old_count,
|
||
"new_start": new_start,
|
||
"new_count": new_count,
|
||
"before_hidden": 0,
|
||
"lines": [],
|
||
}
|
||
old_line = old_start
|
||
new_line = new_start
|
||
continue
|
||
if current_hunk is None:
|
||
continue
|
||
marker = raw[:1]
|
||
if marker not in {" ", "+", "-"}:
|
||
continue
|
||
text = raw[1:]
|
||
line = {"kind": "context", "old_line": old_line, "new_line": new_line, "text": text}
|
||
if marker == "+":
|
||
line = {"kind": "add", "old_line": None, "new_line": new_line, "text": text}
|
||
new_line += 1
|
||
current["additions"] += 1
|
||
elif marker == "-":
|
||
line = {"kind": "delete", "old_line": old_line, "new_line": None, "text": text}
|
||
old_line += 1
|
||
current["deletions"] += 1
|
||
else:
|
||
old_line += 1
|
||
new_line += 1
|
||
current_hunk["lines"].append(line)
|
||
finish_file()
|
||
return [item for item in files if item.get("path") and item.get("hunks")]
|
||
|
||
|
||
def _open_path_in_file_manager(path: Path) -> bool:
|
||
target = path.expanduser().resolve()
|
||
if not target.exists():
|
||
return False
|
||
try:
|
||
if sys.platform == "darwin":
|
||
subprocess.Popen(["open", str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
return True
|
||
if os.name == "nt":
|
||
os.startfile(str(target)) # type: ignore[attr-defined]
|
||
return True
|
||
opener = shutil.which("xdg-open")
|
||
if opener:
|
||
subprocess.Popen([opener, str(target)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
|
||
def _resolve_project_git_file(workspace, rel_path: str) -> tuple[Path | None, str]:
|
||
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
|
||
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
|
||
repo_root = Path(root_text).expanduser().resolve() if ok and root_text else project_path
|
||
try:
|
||
target = (repo_root / rel_path).expanduser().resolve()
|
||
target.relative_to(repo_root)
|
||
except Exception:
|
||
return None, "文件路径无效"
|
||
if not target.exists() or not target.is_file():
|
||
return None, "文件不存在"
|
||
return target, ""
|
||
|
||
|
||
def _mac_open_app_candidates(file_path: Path | None = None) -> list[dict]:
|
||
app_paths: list[Path] = []
|
||
default_app_path = ""
|
||
if file_path is not None:
|
||
try:
|
||
from AppKit import NSWorkspace
|
||
from Foundation import NSURL
|
||
file_url = NSURL.fileURLWithPath_(str(file_path))
|
||
workspace = NSWorkspace.sharedWorkspace()
|
||
urls = workspace.URLsForApplicationsToOpenURL_(file_url) or []
|
||
app_paths.extend(Path(str(url.path())) for url in urls if url and url.path())
|
||
default_url = workspace.URLForApplicationToOpenURL_(file_url)
|
||
if default_url and default_url.path():
|
||
default_app_path = str(Path(str(default_url.path())).expanduser().absolute())
|
||
except Exception:
|
||
app_paths = []
|
||
if not app_paths:
|
||
# 只在 LaunchServices 不可用时做很小的兜底;正常路径必须使用系统针对该文件的结果。
|
||
for base in ["/System/Applications/TextEdit.app", "/Applications/Xcode.app", "/Applications/Cursor.app"]:
|
||
path = Path(base).expanduser()
|
||
if path.exists():
|
||
app_paths.append(path)
|
||
seen = set()
|
||
apps: list[dict] = []
|
||
for path in app_paths:
|
||
try:
|
||
visible_path = path.expanduser().absolute()
|
||
except Exception:
|
||
continue
|
||
path_text = str(visible_path)
|
||
if (
|
||
path_text in seen
|
||
or "__pycache__" in path_text
|
||
or "/Library/Application Support/" in path_text
|
||
or "/System/Library/PrivateFrameworks/" in path_text
|
||
or "/System/Library/CoreServices/" in path_text
|
||
or "/System/Library/Services/" in path_text
|
||
):
|
||
continue
|
||
info = _read_mac_app_info(visible_path)
|
||
label = info.get("label") or visible_path.stem
|
||
bundle_id = str(info.get("bundle_id") or "")
|
||
if bundle_id == "com.microsoft.VSCode" or label == "Code":
|
||
label = visible_path.stem
|
||
label_lower = label.lower()
|
||
if label_lower.endswith("agent") or label_lower in {"finder", "system settings"}:
|
||
continue
|
||
seen.add(path_text)
|
||
apps.append({
|
||
"id": path_text,
|
||
"label": f"{label}(默认)" if default_app_path and path_text == default_app_path else label,
|
||
"bundle_id": bundle_id,
|
||
"icon_url": f"/api/project/app-icon?app_id={path_text}",
|
||
"rank": 0 if default_app_path and path_text == default_app_path else 1,
|
||
})
|
||
apps.sort(key=lambda item: (item.get("rank", 1000), item.get("label", "").lower()))
|
||
return [{k: v for k, v in item.items() if k != "rank"} for item in apps[:40]]
|
||
|
||
|
||
def _read_mac_app_info(app_path: Path) -> dict:
|
||
info_path = app_path / "Contents" / "Info.plist"
|
||
try:
|
||
data = plistlib.loads(info_path.read_bytes())
|
||
except Exception:
|
||
data = {}
|
||
return {
|
||
"label": data.get("CFBundleDisplayName") or data.get("CFBundleName") or app_path.stem,
|
||
"bundle_id": data.get("CFBundleIdentifier") or "",
|
||
"icon_file": data.get("CFBundleIconFile") or "",
|
||
}
|
||
|
||
|
||
def _mac_app_icon_path(app_path: Path) -> Path | None:
|
||
info = _read_mac_app_info(app_path)
|
||
icon_name = str(info.get("icon_file") or "").strip()
|
||
resources = app_path / "Contents" / "Resources"
|
||
candidates = []
|
||
if icon_name:
|
||
candidates.append(resources / icon_name)
|
||
if not icon_name.endswith(".icns"):
|
||
candidates.append(resources / f"{icon_name}.icns")
|
||
candidates.extend(resources.glob("*.icns"))
|
||
for candidate in candidates:
|
||
if candidate.exists():
|
||
return candidate
|
||
return None
|
||
|
||
|
||
def _windows_open_app_candidates(file_path: Path | None = None) -> list[dict]:
|
||
if os.name != "nt" or file_path is None:
|
||
return []
|
||
try:
|
||
import ctypes
|
||
import shlex
|
||
import winreg
|
||
except Exception:
|
||
return []
|
||
|
||
ext = file_path.suffix.lower()
|
||
if not ext:
|
||
return []
|
||
|
||
def expand_env(value: str) -> str:
|
||
try:
|
||
return os.path.expandvars(value)
|
||
except Exception:
|
||
return value
|
||
|
||
def parse_command_exe(command: str) -> str:
|
||
command = expand_env((command or "").strip())
|
||
if not command:
|
||
return ""
|
||
try:
|
||
parts = shlex.split(command, posix=False)
|
||
if parts:
|
||
return parts[0].strip('"')
|
||
except Exception:
|
||
pass
|
||
if command.startswith('"'):
|
||
end = command.find('"', 1)
|
||
return command[1:end] if end > 1 else ""
|
||
return command.split(" ", 1)[0].strip('"')
|
||
|
||
def read_reg_value(root, subkey: str, value_name: str = "") -> str:
|
||
try:
|
||
with winreg.OpenKey(root, subkey) as key:
|
||
value, _ = winreg.QueryValueEx(key, value_name)
|
||
return str(value or "")
|
||
except Exception:
|
||
return ""
|
||
|
||
def enum_subkey_values(root, subkey: str) -> list[str]:
|
||
values: list[str] = []
|
||
try:
|
||
with winreg.OpenKey(root, subkey) as key:
|
||
idx = 0
|
||
while True:
|
||
try:
|
||
name, value, _ = winreg.EnumValue(key, idx)
|
||
idx += 1
|
||
if name and name != "MRUList":
|
||
values.append(str(value or name))
|
||
except OSError:
|
||
break
|
||
except Exception:
|
||
pass
|
||
return values
|
||
|
||
def command_for_progid(progid: str) -> str:
|
||
for root in (winreg.HKEY_CURRENT_USER, winreg.HKEY_CLASSES_ROOT, winreg.HKEY_LOCAL_MACHINE):
|
||
for prefix in ("Software\\Classes\\", ""):
|
||
command = read_reg_value(root, f"{prefix}{progid}\\shell\\open\\command")
|
||
if command:
|
||
return command
|
||
return ""
|
||
|
||
def app_label(exe: Path, default: bool = False) -> str:
|
||
stem = exe.stem
|
||
label_map = {
|
||
"notepad": "记事本",
|
||
"code": "Visual Studio Code",
|
||
"cursor": "Cursor",
|
||
"wordpad": "写字板",
|
||
}
|
||
label = label_map.get(stem.lower(), stem)
|
||
return f"{label}(默认)" if default else label
|
||
|
||
candidates: list[tuple[str, bool]] = []
|
||
|
||
# 默认打开程序:Windows Shell 关联查询,等价于系统“打开方式”的默认项。
|
||
try:
|
||
ASSOCF_NONE = 0
|
||
ASSOCSTR_EXECUTABLE = 2
|
||
buffer_len = ctypes.c_ulong(0)
|
||
ctypes.windll.Shlwapi.AssocQueryStringW(
|
||
ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, None, ctypes.byref(buffer_len)
|
||
)
|
||
if buffer_len.value > 0:
|
||
buffer = ctypes.create_unicode_buffer(buffer_len.value)
|
||
result = ctypes.windll.Shlwapi.AssocQueryStringW(
|
||
ASSOCF_NONE, ASSOCSTR_EXECUTABLE, ext, None, buffer, ctypes.byref(buffer_len)
|
||
)
|
||
if result == 0 and buffer.value:
|
||
candidates.append((buffer.value, True))
|
||
except Exception:
|
||
pass
|
||
|
||
progids = set()
|
||
for root, subkey in (
|
||
(winreg.HKEY_CURRENT_USER, f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithProgids"),
|
||
(winreg.HKEY_CLASSES_ROOT, f"{ext}\\OpenWithProgids"),
|
||
):
|
||
progids.update(enum_subkey_values(root, subkey))
|
||
default_progid = read_reg_value(winreg.HKEY_CLASSES_ROOT, ext)
|
||
if default_progid:
|
||
progids.add(default_progid)
|
||
user_choice = read_reg_value(
|
||
winreg.HKEY_CURRENT_USER,
|
||
f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\UserChoice",
|
||
"ProgId",
|
||
)
|
||
if user_choice:
|
||
progids.add(user_choice)
|
||
|
||
for progid in progids:
|
||
command = command_for_progid(progid)
|
||
exe = parse_command_exe(command)
|
||
if exe:
|
||
candidates.append((exe, False))
|
||
|
||
app_names = enum_subkey_values(
|
||
winreg.HKEY_CURRENT_USER,
|
||
f"Software\\Microsoft\\Windows\\CurrentVersion\\Explorer\\FileExts\\{ext}\\OpenWithList",
|
||
)
|
||
app_names += enum_subkey_values(
|
||
winreg.HKEY_CLASSES_ROOT,
|
||
f"{ext}\\OpenWithList",
|
||
)
|
||
for app_name in app_names:
|
||
app_key = f"Applications\\{app_name}\\shell\\open\\command"
|
||
command = read_reg_value(winreg.HKEY_CLASSES_ROOT, app_key)
|
||
exe = parse_command_exe(command)
|
||
if exe:
|
||
candidates.append((exe, False))
|
||
|
||
seen = set()
|
||
apps = []
|
||
for exe, is_default in candidates:
|
||
try:
|
||
path = Path(expand_env(exe)).expanduser().resolve()
|
||
except Exception:
|
||
continue
|
||
key = str(path).lower()
|
||
if key in seen or not path.exists() or path.suffix.lower() != ".exe":
|
||
continue
|
||
seen.add(key)
|
||
apps.append({"id": str(path), "label": app_label(path, is_default)})
|
||
return apps[:40]
|
||
|
||
|
||
def _open_file_with_app(file_path: Path, app_id: str) -> bool:
|
||
try:
|
||
if sys.platform == "darwin":
|
||
app_path = Path(app_id).expanduser().resolve()
|
||
if not app_path.exists() or app_path.suffix != ".app":
|
||
return False
|
||
subprocess.Popen(["open", "-a", str(app_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
return True
|
||
if os.name == "nt":
|
||
exe_path = Path(app_id).expanduser().resolve()
|
||
if not exe_path.exists() or exe_path.suffix.lower() != ".exe":
|
||
return False
|
||
subprocess.Popen([str(exe_path), str(file_path)], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
|
||
return True
|
||
except Exception:
|
||
return False
|
||
return False
|
||
|
||
|
||
@status_bp.route('/api/health')
|
||
@api_login_required
|
||
def get_health():
|
||
"""轻量健康检查:用于前端连接心跳,避免触发重型 status 计算。"""
|
||
started_at = time.perf_counter()
|
||
heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip()
|
||
username = session.get("username") or "-"
|
||
payload = {
|
||
"success": True,
|
||
"status": "ok",
|
||
"server_time_ms": int(time.time() * 1000),
|
||
}
|
||
elapsed_ms = (time.perf_counter() - started_at) * 1000
|
||
if request.args.get("diag") == "1" or elapsed_ms >= 800:
|
||
log_conn_diag(
|
||
f"health hb={heartbeat_id or '-'} user={username} "
|
||
f"elapsed_ms={elapsed_ms:.1f} host_mode={bool(session.get('host_mode'))} "
|
||
f"workspace_id={session.get('workspace_id') or '-'}"
|
||
)
|
||
return jsonify(payload)
|
||
|
||
|
||
@status_bp.route('/api/status')
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_status(terminal, workspace, username):
|
||
"""获取系统状态(包含对话、容器、版本等信息)"""
|
||
started_at = time.perf_counter()
|
||
heartbeat_id = request.headers.get("X-Connection-Heartbeat", "").strip()
|
||
diag_requested = STATUS_DIAG_VERBOSE or request.args.get("diag") == "1"
|
||
timings = {
|
||
"terminal_ms": 0.0,
|
||
"terminals_ms": 0.0,
|
||
"conversation_meta_ms": 0.0,
|
||
"container_ms": 0.0,
|
||
"policy_ms": 0.0,
|
||
}
|
||
phase_started_at = time.perf_counter()
|
||
try:
|
||
status = terminal.get_status()
|
||
except Exception as exc:
|
||
timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||
total_ms = (time.perf_counter() - started_at) * 1000
|
||
log_conn_diag(
|
||
f"status hb={heartbeat_id or '-'} user={username} phase=terminal.get_status "
|
||
f"total_ms={total_ms:.1f} terminal_ms={timings['terminal_ms']:.1f} error={exc}"
|
||
)
|
||
raise
|
||
timings["terminal_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||
phase_started_at = time.perf_counter()
|
||
if terminal.terminal_manager:
|
||
status['terminals'] = terminal.terminal_manager.list_terminals()
|
||
timings["terminals_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||
phase_started_at = time.perf_counter()
|
||
try:
|
||
current_conv = terminal.context_manager.current_conversation_id
|
||
status.setdefault('conversation', {})['current_id'] = current_conv
|
||
if current_conv and not current_conv.startswith('temp_'):
|
||
current_conv_data = terminal.context_manager.conversation_manager.load_conversation(current_conv)
|
||
if current_conv_data:
|
||
status['conversation']['title'] = current_conv_data.get('title', '未知对话')
|
||
status['conversation']['created_at'] = current_conv_data.get('created_at')
|
||
status['conversation']['updated_at'] = current_conv_data.get('updated_at')
|
||
meta = current_conv_data.get("metadata", {}) or {}
|
||
status['conversation']['compression'] = {
|
||
"in_progress": bool(meta.get("compression_in_progress", False)),
|
||
"mode": meta.get("compression_mode"),
|
||
"stage": meta.get("compression_stage"),
|
||
"error": meta.get("compression_error"),
|
||
"count": int(meta.get("compression_count", 0) or 0),
|
||
"job_id": meta.get("compression_job_id"),
|
||
}
|
||
except Exception as exc:
|
||
log_conn_diag(f"status conversation-meta-failed user={username} error={exc}")
|
||
timings["conversation_meta_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||
status['project_path'] = str(workspace.project_path)
|
||
phase_started_at = time.perf_counter()
|
||
try:
|
||
# 首屏状态只需要容器是否存在/运行等轻量信息;Docker stats 很慢,
|
||
# 由 /api/container-status 在用量面板需要时单独拉取。
|
||
workspace_id = getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'
|
||
container_key = (
|
||
f"host::{workspace_id}"
|
||
if bool(session.get("host_mode")) or username == "host"
|
||
else f"{username}::{workspace_id}"
|
||
)
|
||
status['container'] = container_manager.get_container_status(container_key, include_stats=False)
|
||
except Exception as exc:
|
||
status['container'] = {"success": False, "error": str(exc)}
|
||
log_conn_diag(f"status container-status-failed user={username} error={exc}")
|
||
timings["container_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||
status['version'] = AGENT_VERSION
|
||
phase_started_at = time.perf_counter()
|
||
try:
|
||
policy = resolve_admin_policy(user_manager.get_user(username))
|
||
status['admin_policy'] = {
|
||
"ui_blocks": policy.get("ui_blocks") or {},
|
||
"disabled_models": policy.get("disabled_models") or [],
|
||
"forced_category_states": policy.get("forced_category_states") or {},
|
||
"version": policy.get("updated_at"),
|
||
}
|
||
except Exception:
|
||
pass
|
||
timings["policy_ms"] = (time.perf_counter() - phase_started_at) * 1000
|
||
total_ms = (time.perf_counter() - started_at) * 1000
|
||
is_slow = total_ms >= STATUS_DIAG_SLOW_MS
|
||
if diag_requested or is_slow:
|
||
conversation = status.get("conversation") or {}
|
||
log_conn_diag(
|
||
f"status hb={heartbeat_id or '-'} user={username} host_mode={bool(session.get('host_mode'))} "
|
||
f"workspace_id={session.get('workspace_id') or '-'} "
|
||
f"conversation_id={conversation.get('current_id') or '-'} "
|
||
f"total_ms={total_ms:.1f} slow={is_slow} "
|
||
f"terminal_ms={timings['terminal_ms']:.1f} terminals_ms={timings['terminals_ms']:.1f} "
|
||
f"conversation_meta_ms={timings['conversation_meta_ms']:.1f} "
|
||
f"container_ms={timings['container_ms']:.1f} policy_ms={timings['policy_ms']:.1f}"
|
||
)
|
||
return jsonify(status)
|
||
|
||
|
||
@status_bp.route('/api/project/git-summary')
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_project_git_summary(terminal, workspace, username):
|
||
"""返回当前项目自身 git 仓库概览(不使用隐藏版本管理 git)。"""
|
||
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
|
||
if not project_path.exists():
|
||
return jsonify({"success": True, "data": {"has_git": False}})
|
||
|
||
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
|
||
if not ok or not root_text:
|
||
return jsonify({"success": True, "data": {"has_git": False}})
|
||
|
||
repo_root = Path(root_text).expanduser().resolve()
|
||
ok, branch = _run_project_git(repo_root, ["branch", "--show-current"])
|
||
if not ok or not branch:
|
||
ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"])
|
||
branch = f"HEAD {branch}" if ok and branch else "HEAD"
|
||
_, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
||
|
||
_, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"])
|
||
additions, deletions = _sum_git_numstat(numstat_text)
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"has_git": True,
|
||
"project_name": repo_root.name,
|
||
"project_path": str(repo_root),
|
||
"branch": branch,
|
||
"upstream": upstream,
|
||
"additions": additions,
|
||
"deletions": deletions,
|
||
}
|
||
})
|
||
|
||
|
||
@status_bp.route('/api/project/git-diff')
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_project_git_diff(terminal, workspace, username):
|
||
"""返回当前项目自身 git 工作区 diff,用于前端侧边栏展示。"""
|
||
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
|
||
if not project_path.exists():
|
||
return jsonify({"success": True, "data": {"has_git": False, "files": []}})
|
||
|
||
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
|
||
if not ok or not root_text:
|
||
return jsonify({"success": True, "data": {"has_git": False, "files": []}})
|
||
|
||
repo_root = Path(root_text).expanduser().resolve()
|
||
try:
|
||
context_lines = int(request.args.get("context", "3") or 3)
|
||
except Exception:
|
||
context_lines = 3
|
||
context_lines = max(0, min(200, context_lines))
|
||
context_map: dict[str, int] = {}
|
||
raw_context_map = request.args.get("context_map", "").strip()
|
||
if raw_context_map:
|
||
try:
|
||
parsed = json.loads(raw_context_map)
|
||
if isinstance(parsed, dict):
|
||
for key, value in parsed.items():
|
||
if not isinstance(key, str) or not key:
|
||
continue
|
||
try:
|
||
context_map[key] = max(0, min(200, int(value)))
|
||
except Exception:
|
||
continue
|
||
except Exception:
|
||
context_map = {}
|
||
fold_context_map: dict[str, dict[str, int]] = {}
|
||
raw_fold_context_map = request.args.get("fold_context_map", "").strip()
|
||
if raw_fold_context_map:
|
||
try:
|
||
parsed = json.loads(raw_fold_context_map)
|
||
if isinstance(parsed, dict):
|
||
for path_key, folds in parsed.items():
|
||
if not isinstance(path_key, str) or not isinstance(folds, dict):
|
||
continue
|
||
safe_folds: dict[str, int] = {}
|
||
for fold_key, value in folds.items():
|
||
if not isinstance(fold_key, str) or not fold_key:
|
||
continue
|
||
try:
|
||
safe_folds[fold_key] = max(0, min(200, int(value)))
|
||
except Exception:
|
||
continue
|
||
if safe_folds:
|
||
fold_context_map[path_key] = safe_folds
|
||
except Exception:
|
||
fold_context_map = {}
|
||
|
||
ok, branch = _run_project_git(repo_root, ["branch", "--show-current"])
|
||
if not ok or not branch:
|
||
ok, branch = _run_project_git(repo_root, ["rev-parse", "--short", "HEAD"])
|
||
branch = f"HEAD {branch}" if ok and branch else "HEAD"
|
||
_, upstream = _run_project_git(repo_root, ["rev-parse", "--abbrev-ref", "--symbolic-full-name", "@{u}"])
|
||
_, numstat_text = _run_project_git(repo_root, ["diff", "--numstat", "HEAD", "--"])
|
||
additions, deletions = _sum_git_numstat(numstat_text)
|
||
diff_text = ""
|
||
if context_map:
|
||
parts: list[str] = []
|
||
for rel_path in _paths_from_git_numstat(numstat_text):
|
||
per_file_context = context_map.get(rel_path, context_lines)
|
||
ok, file_diff = _run_project_git_raw(
|
||
repo_root,
|
||
["--no-pager", "diff", f"--unified={per_file_context}", "HEAD", "--", rel_path],
|
||
timeout=8,
|
||
)
|
||
if ok and file_diff:
|
||
parts.append(file_diff)
|
||
diff_text = "\n".join(parts)
|
||
else:
|
||
ok, diff_text = _run_project_git_raw(repo_root, [f"--no-pager", "diff", f"--unified={context_lines}", "HEAD", "--"], timeout=8)
|
||
if not ok:
|
||
diff_text = ""
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"has_git": True,
|
||
"project_name": repo_root.name,
|
||
"project_path": str(repo_root),
|
||
"branch": branch,
|
||
"upstream": upstream,
|
||
"additions": additions,
|
||
"deletions": deletions,
|
||
"context": context_lines,
|
||
"files": _parse_git_diff(repo_root, diff_text, fold_context_map=fold_context_map),
|
||
}
|
||
})
|
||
|
||
|
||
@status_bp.route('/api/project/open-in-file-manager', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def open_project_in_file_manager(terminal, workspace, username):
|
||
project_path = Path(getattr(workspace, "project_path", "") or "").expanduser().resolve()
|
||
ok, root_text = _run_project_git(project_path, ["rev-parse", "--show-toplevel"])
|
||
target = Path(root_text).expanduser().resolve() if ok and root_text else project_path
|
||
if _open_path_in_file_manager(target):
|
||
return jsonify({"success": True})
|
||
return jsonify({"success": False, "error": "无法打开文件管理器"}), 500
|
||
|
||
|
||
@status_bp.route('/api/project/file-open-apps')
|
||
@api_login_required
|
||
@with_terminal
|
||
def list_project_file_open_apps(terminal, workspace, username):
|
||
if not _is_host_mode_request():
|
||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||
rel_path = request.args.get("path", "").strip()
|
||
target, error = _resolve_project_git_file(workspace, rel_path)
|
||
if not target:
|
||
return jsonify({"success": False, "error": error or "文件路径无效"}), 400
|
||
if sys.platform == "darwin":
|
||
apps = _mac_open_app_candidates(target)
|
||
elif os.name == "nt":
|
||
apps = _windows_open_app_candidates(target)
|
||
else:
|
||
apps = []
|
||
return jsonify({"success": True, "data": {"apps": apps}})
|
||
|
||
|
||
@status_bp.route('/api/project/app-icon')
|
||
@api_login_required
|
||
def get_project_open_app_icon():
|
||
if not _is_host_mode_request():
|
||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||
app_id = request.args.get("app_id", "").strip()
|
||
if sys.platform != "darwin" or not app_id:
|
||
return jsonify({"success": False, "error": "应用图标不可用"}), 404
|
||
try:
|
||
app_path = Path(app_id).expanduser().resolve()
|
||
if not app_path.exists() or app_path.suffix != ".app":
|
||
return jsonify({"success": False, "error": "应用不存在"}), 404
|
||
icon_path = _mac_app_icon_path(app_path)
|
||
if not icon_path:
|
||
return jsonify({"success": False, "error": "应用图标不存在"}), 404
|
||
cache_dir = Path(tempfile.gettempdir()) / "agents_app_icons"
|
||
cache_dir.mkdir(parents=True, exist_ok=True)
|
||
out_path = cache_dir / f"{abs(hash(str(app_path)))}.png"
|
||
if not out_path.exists() or out_path.stat().st_mtime < icon_path.stat().st_mtime:
|
||
sips = shutil.which("sips")
|
||
if not sips:
|
||
return jsonify({"success": False, "error": "图标转换工具不可用"}), 404
|
||
subprocess.run(
|
||
[sips, "-s", "format", "png", str(icon_path), "--out", str(out_path)],
|
||
stdout=subprocess.DEVNULL,
|
||
stderr=subprocess.DEVNULL,
|
||
timeout=5,
|
||
check=False,
|
||
)
|
||
if out_path.exists():
|
||
return send_file(str(out_path), mimetype="image/png")
|
||
except Exception:
|
||
pass
|
||
return jsonify({"success": False, "error": "应用图标不可用"}), 404
|
||
|
||
|
||
@status_bp.route('/api/project/open-file-with-app', methods=['POST'])
|
||
@api_login_required
|
||
@with_terminal
|
||
def open_project_file_with_app(terminal, workspace, username):
|
||
if not _is_host_mode_request():
|
||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||
payload = request.get_json(silent=True) or {}
|
||
rel_path = str(payload.get("path") or "").strip()
|
||
app_id = str(payload.get("app_id") or "").strip()
|
||
if not app_id:
|
||
return jsonify({"success": False, "error": "未选择应用"}), 400
|
||
target, error = _resolve_project_git_file(workspace, rel_path)
|
||
if not target:
|
||
return jsonify({"success": False, "error": error or "文件路径无效"}), 400
|
||
available = _mac_open_app_candidates(target) if sys.platform == "darwin" else _windows_open_app_candidates(target) if os.name == "nt" else []
|
||
if not any(item.get("id") == app_id for item in available):
|
||
return jsonify({"success": False, "error": "应用不可用"}), 400
|
||
if _open_file_with_app(target, app_id):
|
||
return jsonify({"success": True})
|
||
return jsonify({"success": False, "error": "打开文件失败"}), 500
|
||
|
||
|
||
@status_bp.route('/api/host/workspaces')
|
||
@api_login_required
|
||
def list_host_workspaces():
|
||
if not _is_host_mode_request():
|
||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||
|
||
catalog, current = resolve_host_workspace(session.get("host_workspace_id"))
|
||
current_id = current.get("workspace_id")
|
||
session['host_workspace_id'] = current_id
|
||
session['workspace_id'] = current_id
|
||
|
||
workspaces = _build_host_workspaces_payload(catalog, current_id)
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"source_path": catalog.get("source_path"),
|
||
"default_workspace_id": catalog.get("default_workspace_id"),
|
||
"current_workspace_id": current_id,
|
||
"workspaces": 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)
|
||
default_workspace_id = user_manager._get_user_default_workspace_id(username)
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"default_workspace_id": default_workspace_id,
|
||
"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
|
||
default_workspace_id = user_manager._get_user_default_workspace_id(username)
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"current_workspace_id": session["workspace_id"],
|
||
"project_path": "",
|
||
"default_workspace_id": default_workspace_id,
|
||
"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"
|
||
default_workspace_id = user_manager._get_user_default_workspace_id(username)
|
||
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_workspace_id,
|
||
})
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"created": True,
|
||
"workspace": {
|
||
"workspace_id": getattr(workspace, "workspace_id", ""),
|
||
"label": label,
|
||
"path": "",
|
||
},
|
||
"default_workspace_id": default_workspace_id,
|
||
"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"
|
||
default_workspace_id = user_manager._get_user_default_workspace_id(username)
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"workspace": workspace,
|
||
"default_workspace_id": default_workspace_id,
|
||
"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
|
||
|
||
# 如果删除的是当前默认项目,重置为 default
|
||
if user_manager._get_user_default_workspace_id(username) == workspace_id:
|
||
try:
|
||
user_manager.set_default_user_workspace(username, "default")
|
||
except Exception:
|
||
pass
|
||
|
||
default_workspace_id = user_manager._get_user_default_workspace_id(username)
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"deleted_workspace_id": workspace_id,
|
||
"default_workspace_id": default_workspace_id,
|
||
"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():
|
||
if not _is_host_mode_request():
|
||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 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": "缺少 workspace_id"}), 400
|
||
write_host_workspace_debug(
|
||
"status.select_host_workspace.request",
|
||
workspace_id=workspace_id,
|
||
current_session_workspace_id=session.get("host_workspace_id"),
|
||
)
|
||
|
||
catalog = load_host_workspace_catalog()
|
||
current_id = (session.get("host_workspace_id") or "").strip()
|
||
target = next(
|
||
(item for item in (catalog.get("workspaces") or []) if item.get("workspace_id") == workspace_id),
|
||
None,
|
||
)
|
||
if not target:
|
||
return jsonify({"success": False, "error": "workspace_id 不存在"}), 404
|
||
target_path = str(Path(target.get("path") or "").expanduser().resolve())
|
||
write_host_workspace_debug(
|
||
"status.select_host_workspace.target",
|
||
workspace_id=workspace_id,
|
||
target_path=target_path,
|
||
current_workspace_id=current_id,
|
||
)
|
||
|
||
previous_workspace_id = session.get("workspace_id")
|
||
with state.HOST_ACTIVE_WORKSPACE_LOCK:
|
||
state.HOST_ACTIVE_WORKSPACE_ID = workspace_id
|
||
state.HOST_ACTIVE_WORKSPACE_PATH = target_path
|
||
state.HOST_ACTIVE_WORKSPACE_VERSION += 1
|
||
session['host_workspace_id'] = workspace_id
|
||
session['workspace_id'] = workspace_id
|
||
|
||
try:
|
||
container_handle = state.container_manager.ensure_container(
|
||
"host",
|
||
target_path,
|
||
container_key=f"host::{workspace_id}",
|
||
preferred_mode="host",
|
||
)
|
||
except RuntimeError as exc:
|
||
if current_id:
|
||
session['host_workspace_id'] = current_id
|
||
if previous_workspace_id is not None:
|
||
session['workspace_id'] = previous_workspace_id
|
||
elif current_id:
|
||
session['workspace_id'] = current_id
|
||
write_host_workspace_debug(
|
||
"status.select_host_workspace.ensure_container_failed",
|
||
workspace_id=workspace_id,
|
||
target_path=target_path,
|
||
error=str(exc),
|
||
)
|
||
return jsonify({"success": False, "error": str(exc)}), 503
|
||
|
||
host_terminal = state.user_terminals.get(f"host::{workspace_id}")
|
||
if host_terminal:
|
||
try:
|
||
host_terminal.update_container_session(container_handle)
|
||
attach_user_broadcast(host_terminal, "host")
|
||
host_terminal.username = "host"
|
||
host_terminal.user_role = "admin"
|
||
write_host_workspace_debug(
|
||
"status.select_host_workspace.updated_terminal",
|
||
terminal_id=id(host_terminal),
|
||
workspace_id=workspace_id,
|
||
project_path=str(getattr(host_terminal, "project_path", "")),
|
||
context_project_path=str(getattr(getattr(host_terminal, "context_manager", None), "project_path", "")),
|
||
current_conversation_id=getattr(getattr(host_terminal, "context_manager", None), "current_conversation_id", None),
|
||
)
|
||
except Exception:
|
||
try:
|
||
if getattr(host_terminal, "terminal_manager", None):
|
||
host_terminal.terminal_manager.close_all()
|
||
except Exception:
|
||
pass
|
||
state.user_terminals.pop(f"host::{workspace_id}", None)
|
||
write_host_workspace_debug(
|
||
"status.select_host_workspace.drop_terminal_after_error",
|
||
workspace_id=workspace_id,
|
||
)
|
||
|
||
write_host_workspace_debug(
|
||
"status.select_host_workspace.success",
|
||
workspace_id=workspace_id,
|
||
target_path=target_path,
|
||
)
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"current_workspace_id": workspace_id,
|
||
"project_path": target_path,
|
||
"default_workspace_id": catalog.get("default_workspace_id"),
|
||
"reloaded": current_id != workspace_id,
|
||
}
|
||
})
|
||
|
||
|
||
@status_bp.route('/api/host/workspaces/create', methods=['GET', 'POST'])
|
||
@api_login_required
|
||
def create_host_workspace_api():
|
||
if not _is_host_mode_request():
|
||
return jsonify({"success": False, "error": "仅宿主机模式可用"}), 403
|
||
|
||
payload = request.get_json(silent=True) if request.method != "GET" else None
|
||
workspace_path = (
|
||
request.args.get("path")
|
||
or (payload or {}).get("path")
|
||
or ""
|
||
).strip()
|
||
label = (
|
||
request.args.get("label")
|
||
or (payload or {}).get("label")
|
||
or ""
|
||
).strip()
|
||
set_default_raw = (
|
||
request.args.get("set_default")
|
||
or (payload or {}).get("set_default")
|
||
or ""
|
||
)
|
||
set_default = str(set_default_raw).lower() in {"1", "true", "yes", "on"}
|
||
|
||
if not workspace_path:
|
||
return jsonify({"success": False, "error": "缺少 path"}), 400
|
||
|
||
try:
|
||
result = create_host_workspace(workspace_path, label=label, set_default=set_default)
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
catalog = result.get("catalog") or {}
|
||
workspaces = []
|
||
current_id = (session.get("host_workspace_id") or "").strip()
|
||
for item in catalog.get("workspaces") or []:
|
||
workspaces.append({
|
||
"workspace_id": item.get("workspace_id"),
|
||
"label": item.get("label"),
|
||
"path": item.get("path"),
|
||
"is_current": item.get("workspace_id") == current_id,
|
||
})
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"created": bool(result.get("created")),
|
||
"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": workspaces,
|
||
}
|
||
})
|
||
|
||
|
||
@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/host/workspaces/set-default', methods=['POST'])
|
||
@api_login_required
|
||
def set_default_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
|
||
try:
|
||
result = set_default_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()
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"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/projects/set-default', methods=['POST'])
|
||
@api_login_required
|
||
def set_default_docker_project_api():
|
||
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
|
||
username = session.get("username")
|
||
try:
|
||
user_manager.set_default_user_workspace(username, 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
|
||
|
||
current_id = session.get("workspace_id") or "default"
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"default_workspace_id": user_manager._get_user_default_workspace_id(username),
|
||
"current_workspace_id": current_id,
|
||
"workspaces": _build_docker_projects_payload(username, current_id),
|
||
}
|
||
})
|
||
|
||
|
||
@status_bp.route('/api/container-status')
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_container_status_api(terminal, workspace, username):
|
||
try:
|
||
container_key = (
|
||
f"host::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}"
|
||
if bool(session.get("host_mode")) or username == "host"
|
||
else f"{username}::{getattr(workspace, 'workspace_id', None) or session.get('workspace_id') or 'default'}"
|
||
)
|
||
status = container_manager.get_container_status(container_key)
|
||
return jsonify({"success": True, "data": status})
|
||
except Exception as exc:
|
||
return jsonify({"success": False, "error": str(exc)}), 500
|
||
|
||
|
||
@status_bp.route('/api/project-storage')
|
||
@api_login_required
|
||
@with_terminal
|
||
def get_project_storage(terminal, workspace, username):
|
||
now = time.time()
|
||
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:
|
||
file_manager = getattr(terminal, 'file_manager', None)
|
||
if not file_manager:
|
||
return jsonify({"success": False, "error": "文件管理器未初始化"}), 500
|
||
used_bytes = file_manager._get_project_size()
|
||
limit_bytes = PROJECT_MAX_STORAGE_MB * 1024 * 1024 if PROJECT_MAX_STORAGE_MB else None
|
||
usage_percent = (used_bytes / limit_bytes * 100) if limit_bytes else None
|
||
data = {
|
||
"used_bytes": used_bytes,
|
||
"limit_bytes": limit_bytes,
|
||
"limit_label": f"{PROJECT_MAX_STORAGE_MB}MB" if PROJECT_MAX_STORAGE_MB else "未限制",
|
||
"usage_percent": usage_percent
|
||
}
|
||
PROJECT_STORAGE_CACHE[cache_key] = {"ts": now, "data": data}
|
||
return jsonify({"success": True, "data": data})
|
||
except Exception as exc:
|
||
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
|
||
|
||
|
||
@status_bp.route('/api/app/version')
|
||
@api_login_required
|
||
def get_app_version_info():
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
version_code, version_name = _parse_android_version_from_gradle(project_root)
|
||
apk_path = _resolve_android_apk_path(project_root)
|
||
apk_exists = apk_path.exists()
|
||
file_size = apk_path.stat().st_size if apk_exists else 0
|
||
published_at = time.strftime("%Y-%m-%dT%H:%M:%SZ", time.gmtime(apk_path.stat().st_mtime)) if apk_exists else None
|
||
changelog = _load_app_changelog(project_root)
|
||
apk_url = request.url_root.rstrip("/") + "/api/app/apk/latest"
|
||
|
||
current_code_raw = request.args.get("currentVersionCode", "").strip()
|
||
current_code = int(current_code_raw) if current_code_raw.isdigit() else None
|
||
has_update = (version_code > current_code) if current_code is not None else None
|
||
|
||
return jsonify({
|
||
"success": True,
|
||
"data": {
|
||
"latestVersionCode": version_code,
|
||
"latestVersionName": version_name,
|
||
"apkUrl": apk_url,
|
||
"apkSha256": "",
|
||
"fileSizeBytes": file_size,
|
||
"changelog": changelog,
|
||
"forceUpdate": False,
|
||
"publishedAt": published_at,
|
||
"minSupportedVersionCode": 1,
|
||
"apkExists": apk_exists,
|
||
"hasUpdate": has_update,
|
||
}
|
||
})
|
||
|
||
|
||
@status_bp.route('/api/app/apk/latest')
|
||
def download_latest_apk():
|
||
project_root = Path(__file__).resolve().parent.parent
|
||
apk_path = _resolve_android_apk_path(project_root)
|
||
if not apk_path.exists():
|
||
return jsonify({"success": False, "error": "APK 不存在,请先构建 release 包"}), 404
|
||
return send_file(
|
||
apk_path,
|
||
as_attachment=True,
|
||
download_name="cyj-agent-latest.apk",
|
||
mimetype="application/vnd.android.package-archive",
|
||
conditional=False,
|
||
etag=False,
|
||
max_age=0,
|
||
)
|