agent-Specialization/server/status.py

517 lines
20 KiB
Python
Raw Permalink 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
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,
load_host_workspace_catalog,
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 _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"
@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:
status['container'] = container_manager.get_container_status(username)
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 = []
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": {
"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/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,
)
if current_id == workspace_id:
session['workspace_id'] = workspace_id
try:
container_handle = state.container_manager.ensure_container(
"host",
target_path,
container_key="host",
preferred_mode="host",
)
host_terminal = state.user_terminals.get("host")
if host_terminal:
host_terminal.update_project_path(target_path)
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.same_id_reapply",
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", "")),
)
except Exception:
pass
return jsonify({
"success": True,
"data": {
"current_workspace_id": workspace_id,
"project_path": target_path,
"default_workspace_id": catalog.get("default_workspace_id"),
"reloaded": False,
}
})
try:
from .tasks import task_manager
running = [
rec for rec in task_manager.list_tasks("host")
if rec.status in {"pending", "running", "cancel_requested"}
]
if running:
return jsonify({
"success": False,
"error": "存在运行中的任务,请先停止后再切换工作区",
}), 409
except Exception:
pass
previous_workspace_id = session.get("workspace_id")
session['host_workspace_id'] = workspace_id
session['workspace_id'] = workspace_id
try:
container_handle = state.container_manager.ensure_container(
"host",
target_path,
container_key="host",
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("host")
if host_terminal:
try:
host_terminal.update_project_path(target_path)
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("host", 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": True,
}
})
@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/container-status')
@api_login_required
@with_terminal
def get_container_status_api(terminal, workspace, username):
try:
status = container_manager.get_container_status(username)
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()
cache_entry = PROJECT_STORAGE_CACHE.get(username)
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[username] = {"ts": now, "data": data}
return jsonify({"success": True, "data": data})
except Exception as exc:
stale = PROJECT_STORAGE_CACHE.get(username)
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,
)