agent-Specialization/server/status.py
JOJO 59b81c4551 fix(git): 修复Git变更面板中文文件名八进制转义显示
_run_project_git / _run_project_git_raw 添加 -c core.quotePath=false,
让 git 直接输出 UTF-8 原始路径名而非八进制转义形式。
2026-06-09 11:42:20 +08:00

1574 lines
62 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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,
)
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)
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"
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)
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():
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/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,
)