Implement Docker Web project management on top of workspace_id while keeping host mode workspace behavior intact.
Backend changes:
- Move Docker user files to users/<user>/projects/<project_id>/{project,data,logs} and shared user state to users/<user>/personal.
- Add automatic legacy migration from old project/data/logs/agentskills layouts and from the temporary project/shared layout.
- Generate project IDs server-side from the project name, reserve internal IDs, and hide project paths from the UI.
- Add project rename/delete APIs; deleting a Docker project removes its project directory and releases terminal/container state.
- Add host workspace rename/delete APIs; deleting a host workspace only removes catalog entries and keeps files on disk.
- Scope Docker containers, terminals, uploads, conversations, storage, and running task metadata by workspace_id.
- Store private skills under users/<user>/personal/agentskills and sync them into each project workspace.
- Inject the current project/workspace name through prompts/workspace_system.txt.
Frontend changes:
- Replace Docker file-tree area with project management UX and reuse host multi-workspace running-task behavior.
- Add a management dialog for creating, renaming, and deleting projects/workspaces.
- Show project labels instead of internal IDs in toasts and project lists.
- Clear stale conversation history during project/workspace switching and show loading until the new list is ready.
- Keep running-task loader/check behavior consistent across host workspaces and Docker projects.
Validation:
- python3 -m py_compile modules/user_manager.py modules/host_workspace_manager.py modules/skills_manager.py core/main_terminal_parts/tools_execution.py server/auth.py server/context.py server/status.py server/conversation.py server/tasks.py utils/context_manager.py
- python3 -m unittest test.test_server_refactor_smoke
- npm run build --silent 2>&1 | tail -n 20
805 lines
33 KiB
Python
805 lines
33 KiB
Python
from __future__ import annotations
|
||
import time
|
||
import re
|
||
import os
|
||
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
|
||
|
||
|
||
@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/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,
|
||
)
|