Refactor conversation persistence into workspace-scoped storage with host workspace legacy migration, current workspace filtering, and active host workspace state protection. Add proactive recent-conversation prompt injection with personal-space controls, configurable item count, empty-conversation filtering, and frozen per-conversation prompt metadata to keep request caches stable. Introduce conversation_search and conversation_review tools for current-workspace history: multi-keyword search, list mode, current conversation exclusion, message/tool counts, formatted model output, read/save review modes, long-review fallback, and .agents/review storage. Move internal workspace artifacts under .agents, including uploads, skills, compact results, and reviews; update prompt file-tree visibility, compression output, upload paths, and hidden versioning exclusions. Add create_skill workflow and a Skills tool category, move read_skill into Skills, validate/归档 skill folders, support host global skills and Docker/API private skills, and sync private skills into .agents/skills and skill prompts. Update frontend tool icons, statuses, and expanded renderers for conversation recall and skills workflows; add workspace conversation and skills-manager tests.
525 lines
21 KiB
Python
525 lines
21 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,
|
||
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:
|
||
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['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")
|
||
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="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,
|
||
)
|