import json import logging import math import os import re import uuid from datetime import datetime, timedelta from typing import Any, Callable, Dict, List, Optional, Tuple from sqlalchemy import delete as sql_delete, func from sqlmodel import Session, select from clients.edge.errors import log_edge_failure from core.database import engine from core.settings import ( BOTS_WORKSPACE_ROOT, DEFAULT_CHAT_PULL_PAGE_SIZE, DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, DEFAULT_PAGE_SIZE, DEFAULT_STT_AUDIO_FILTER, DEFAULT_STT_AUDIO_PREPROCESS, DEFAULT_STT_DEFAULT_LANGUAGE, DEFAULT_STT_FORCE_SIMPLIFIED, DEFAULT_STT_INITIAL_PROMPT, DEFAULT_STT_MAX_AUDIO_SECONDS, DEFAULT_UPLOAD_MAX_MB, DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, STT_DEVICE, STT_ENABLED_DEFAULT, STT_MODEL, ) from models.bot import BotInstance, NanobotImage from models.platform import BotActivityEvent, BotRequestUsage, PlatformSetting from schemas.platform import ( LoadingPageSettings, PlatformActivityItem, PlatformSettingsPayload, PlatformUsageResponse, PlatformUsageItem, PlatformUsageSummary, SystemSettingItem, SystemSettingPayload, ) DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = () DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7 ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days" ACTIVITY_EVENT_PRUNE_INTERVAL = timedelta(minutes=10) OPERATIONAL_ACTIVITY_EVENT_TYPES = { "bot_created", "bot_deployed", "bot_started", "bot_stopped", "bot_warning", "bot_enabled", "bot_disabled", "bot_deactivated", "command_submitted", "command_failed", "history_cleared", } SETTING_KEYS = ( "page_size", "chat_pull_page_size", "command_auto_unlock_seconds", "upload_max_mb", "allowed_attachment_extensions", "workspace_download_extensions", "speech_enabled", ) PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY} DEPRECATED_SETTING_KEYS = { "loading_page", "speech_max_audio_seconds", "speech_default_language", "speech_force_simplified", "speech_audio_preprocess", "speech_audio_filter", "speech_initial_prompt", } SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "page_size": { "name": "分页大小", "category": "ui", "description": "平台各类列表默认每页条数。", "value_type": "integer", "value": DEFAULT_PAGE_SIZE, "is_public": True, "sort_order": 5, }, "chat_pull_page_size": { "name": "对话懒加载条数", "category": "chat", "description": "Bot 对话区向上懒加载时每次读取的消息条数。", "value_type": "integer", "value": DEFAULT_CHAT_PULL_PAGE_SIZE, "is_public": True, "sort_order": 8, }, "command_auto_unlock_seconds": { "name": "发送按钮自动恢复秒数", "category": "chat", "description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。", "value_type": "integer", "value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, "is_public": True, "sort_order": 9, }, "upload_max_mb": { "name": "上传大小限制", "category": "upload", "description": "单文件上传大小限制,单位 MB。", "value_type": "integer", "value": DEFAULT_UPLOAD_MAX_MB, "is_public": False, "sort_order": 10, }, "allowed_attachment_extensions": { "name": "允许附件后缀", "category": "upload", "description": "允许上传的附件后缀列表,留空表示不限制。", "value_type": "json", "value": list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS), "is_public": False, "sort_order": 20, }, "workspace_download_extensions": { "name": "工作区下载后缀", "category": "workspace", "description": "命中后缀的工作区文件默认走下载模式。", "value_type": "json", "value": list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS), "is_public": False, "sort_order": 30, }, "speech_enabled": { "name": "语音识别开关", "category": "speech", "description": "控制 Bot 语音转写功能是否启用。", "value_type": "boolean", "value": STT_ENABLED_DEFAULT, "is_public": True, "sort_order": 32, }, ACTIVITY_EVENT_RETENTION_SETTING_KEY: { "name": "活动事件保留天数", "category": "maintenance", "description": "bot_activity_event 运维事件的保留天数,超期记录会自动清理。", "value_type": "integer", "value": DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS, "is_public": False, "sort_order": 34, }, } _last_activity_event_prune_at: Optional[datetime] = None logger = logging.getLogger(__name__) def _utcnow() -> datetime: return datetime.utcnow() def _normalize_activity_event_retention_days(raw: Any) -> int: try: value = int(raw) except Exception: value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS return max(1, min(3650, value)) def _normalize_extension(raw: Any) -> str: text = str(raw or "").strip().lower() if not text: return "" if text.startswith("*."): text = text[1:] if not text.startswith("."): text = f".{text}" if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text): return "" return text def _normalize_extension_list(rows: Any) -> List[str]: if not isinstance(rows, list): return [] normalized: List[str] = [] for item in rows: ext = _normalize_extension(item) if ext and ext not in normalized: normalized.append(ext) return normalized def _legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int: raw = os.getenv(name) if raw is None: return default try: value = int(str(raw).strip()) except Exception: value = default return max(min_value, min(max_value, value)) def _legacy_env_bool(name: str, default: bool) -> bool: raw = os.getenv(name) if raw is None: return default return str(raw).strip().lower() in {"1", "true", "yes", "on"} def _legacy_env_extensions(name: str, default: List[str]) -> List[str]: raw = os.getenv(name) if raw is None: return list(default) source = re.split(r"[,;\s]+", str(raw)) normalized: List[str] = [] for item in source: ext = _normalize_extension(item) if ext and ext not in normalized: normalized.append(ext) return normalized def _bootstrap_platform_setting_values() -> Dict[str, Any]: return { "page_size": _legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100), "chat_pull_page_size": _legacy_env_int( "CHAT_PULL_PAGE_SIZE", DEFAULT_CHAT_PULL_PAGE_SIZE, 10, 500, ), "command_auto_unlock_seconds": _legacy_env_int( "COMMAND_AUTO_UNLOCK_SECONDS", DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, 1, 600, ), "upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048), "allowed_attachment_extensions": _legacy_env_extensions( "ALLOWED_ATTACHMENT_EXTENSIONS", list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS), ), "workspace_download_extensions": _legacy_env_extensions( "WORKSPACE_DOWNLOAD_EXTENSIONS", list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS), ), "speech_enabled": _legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT), } def _bot_workspace_root(bot_id: str) -> str: return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace")) def _bot_data_root(bot_id: str) -> str: return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")) def _calc_dir_size_bytes(path: str) -> int: total = 0 if not os.path.isdir(path): return 0 for root, _, files in os.walk(path): for name in files: target = os.path.join(root, name) try: if os.path.islink(target): continue total += int(os.path.getsize(target)) except OSError: continue return total def _workspace_usage_bytes(runtime: Dict[str, Any], bot_id: str) -> int: usage = dict(runtime.get("usage") or {}) value = usage.get("workspace_used_bytes") if value in {None, 0, "0", ""}: value = usage.get("container_rw_bytes") try: normalized = int(value or 0) except Exception: normalized = 0 if normalized > 0: return normalized return _calc_dir_size_bytes(_bot_workspace_root(bot_id)) def _read_bot_resources(bot_id: str) -> Dict[str, Any]: path = os.path.join(_bot_data_root(bot_id), "resources.json") raw: Dict[str, Any] = {} if os.path.isfile(path): try: with open(path, "r", encoding="utf-8") as f: loaded = json.load(f) if isinstance(loaded, dict): raw = loaded except Exception: raw = {} def _safe_float(value: Any, default: float) -> float: try: return float(value) except Exception: return default def _safe_int(value: Any, default: int) -> int: try: return int(value) except Exception: return default cpu = _safe_float(raw.get("cpuCores", raw.get("cpu_cores", 1.0)), 1.0) memory = _safe_int(raw.get("memoryMB", raw.get("memory_mb", 1024)), 1024) storage = _safe_int(raw.get("storageGB", raw.get("storage_gb", 10)), 10) cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) memory = 0 if memory == 0 else min(65536, max(256, memory)) storage = 0 if storage == 0 else min(1024, max(1, storage)) return { "cpu_cores": cpu, "memory_mb": memory, "storage_gb": storage, } def estimate_tokens(text: str) -> int: content = str(text or "").strip() if not content: return 0 pieces = re.findall(r"[\u4e00-\u9fff]|[A-Za-z0-9_]+|[^\s]", content) total = 0 for piece in pieces: if re.fullmatch(r"[\u4e00-\u9fff]", piece): total += 1 elif re.fullmatch(r"[A-Za-z0-9_]+", piece): total += max(1, math.ceil(len(piece) / 4)) else: total += 1 return max(1, total) def default_platform_settings() -> PlatformSettingsPayload: bootstrap = _bootstrap_platform_setting_values() return PlatformSettingsPayload( page_size=int(bootstrap["page_size"]), chat_pull_page_size=int(bootstrap["chat_pull_page_size"]), command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]), upload_max_mb=int(bootstrap["upload_max_mb"]), allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]), workspace_download_extensions=list(bootstrap["workspace_download_extensions"]), speech_enabled=bool(bootstrap["speech_enabled"]), speech_max_audio_seconds=DEFAULT_STT_MAX_AUDIO_SECONDS, speech_default_language=DEFAULT_STT_DEFAULT_LANGUAGE, speech_force_simplified=DEFAULT_STT_FORCE_SIMPLIFIED, speech_audio_preprocess=DEFAULT_STT_AUDIO_PREPROCESS, speech_audio_filter=DEFAULT_STT_AUDIO_FILTER, speech_initial_prompt=DEFAULT_STT_INITIAL_PROMPT, loading_page=LoadingPageSettings(), ) def _normalize_setting_key(raw: Any) -> str: text = str(raw or "").strip() return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower() def _normalize_setting_value(value: Any, value_type: str) -> Any: normalized_type = str(value_type or "json").strip().lower() or "json" if normalized_type == "integer": return int(value or 0) if normalized_type == "float": return float(value or 0) if normalized_type == "boolean": if isinstance(value, bool): return value return str(value or "").strip().lower() in {"1", "true", "yes", "on"} if normalized_type == "string": return str(value or "") if normalized_type == "json": return value raise ValueError(f"Unsupported value_type: {normalized_type}") def _read_setting_value(row: PlatformSetting) -> Any: try: value = json.loads(row.value_json or "null") except Exception: value = None return _normalize_setting_value(value, row.value_type) def _setting_item_from_row(row: PlatformSetting) -> Dict[str, Any]: return SystemSettingItem( key=row.key, name=row.name, category=row.category, description=row.description, value_type=row.value_type, value=_read_setting_value(row), is_public=bool(row.is_public), sort_order=int(row.sort_order or 100), created_at=row.created_at.isoformat() + "Z", updated_at=row.updated_at.isoformat() + "Z", ).model_dump() def _upsert_setting_row( session: Session, key: str, *, name: str, category: str, description: str, value_type: str, value: Any, is_public: bool, sort_order: int, ) -> PlatformSetting: normalized_key = _normalize_setting_key(key) if not normalized_key: raise ValueError("Setting key is required") normalized_type = str(value_type or "json").strip().lower() or "json" normalized_value = _normalize_setting_value(value, normalized_type) now = _utcnow() row = session.get(PlatformSetting, normalized_key) if row is None: row = PlatformSetting( key=normalized_key, name=str(name or normalized_key), category=str(category or "general"), description=str(description or ""), value_type=normalized_type, value_json=json.dumps(normalized_value, ensure_ascii=False), is_public=bool(is_public), sort_order=int(sort_order or 100), created_at=now, updated_at=now, ) else: row.name = str(name or row.name or normalized_key) row.category = str(category or row.category or "general") row.description = str(description or row.description or "") row.value_type = normalized_type row.value_json = json.dumps(normalized_value, ensure_ascii=False) row.is_public = bool(is_public) row.sort_order = int(sort_order or row.sort_order or 100) row.updated_at = now session.add(row) return row def ensure_default_system_settings(session: Session) -> None: bootstrap_values = _bootstrap_platform_setting_values() legacy_row = session.get(PlatformSetting, "global") if legacy_row is not None: try: legacy_data = json.loads(legacy_row.value_json or "{}") except Exception: legacy_data = {} if isinstance(legacy_data, dict): for key in SETTING_KEYS: meta = SYSTEM_SETTING_DEFINITIONS[key] _upsert_setting_row( session, key, name=str(meta["name"]), category=str(meta["category"]), description=str(meta["description"]), value_type=str(meta["value_type"]), value=legacy_data.get(key, bootstrap_values.get(key, meta["value"])), is_public=bool(meta["is_public"]), sort_order=int(meta["sort_order"]), ) session.delete(legacy_row) session.commit() dirty = False for key in DEPRECATED_SETTING_KEYS: legacy_row = session.get(PlatformSetting, key) if legacy_row is not None: session.delete(legacy_row) dirty = True for key, meta in SYSTEM_SETTING_DEFINITIONS.items(): row = session.get(PlatformSetting, key) if row is None: _upsert_setting_row( session, key, name=str(meta["name"]), category=str(meta["category"]), description=str(meta["description"]), value_type=str(meta["value_type"]), value=bootstrap_values.get(key, meta["value"]), is_public=bool(meta["is_public"]), sort_order=int(meta["sort_order"]), ) dirty = True continue changed = False for field in ("name", "category", "description", "value_type"): value = str(meta[field]) if not getattr(row, field): setattr(row, field, value) changed = True if getattr(row, "sort_order", None) is None: row.sort_order = int(meta["sort_order"]) changed = True if getattr(row, "is_public", None) is None: row.is_public = bool(meta["is_public"]) changed = True if changed: row.updated_at = _utcnow() session.add(row) dirty = True if dirty: session.commit() def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]: ensure_default_system_settings(session) stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc()) rows = session.exec(stmt).all() keyword = str(search or "").strip().lower() items = [_setting_item_from_row(row) for row in rows] if not keyword: return items return [ item for item in items if keyword in str(item["key"]).lower() or keyword in str(item["name"]).lower() or keyword in str(item["category"]).lower() or keyword in str(item["description"]).lower() ] def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]: ensure_default_system_settings(session) normalized_key = _normalize_setting_key(payload.key) definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {}) row = _upsert_setting_row( session, payload.key, name=payload.name or str(definition.get("name") or payload.key), category=payload.category or str(definition.get("category") or "general"), description=payload.description or str(definition.get("description") or ""), value_type=payload.value_type or str(definition.get("value_type") or "json"), value=payload.value if payload.value is not None else definition.get("value"), is_public=payload.is_public, sort_order=payload.sort_order or int(definition.get("sort_order") or 100), ) if normalized_key == ACTIVITY_EVENT_RETENTION_SETTING_KEY: prune_expired_activity_events(session, force=True) session.commit() session.refresh(row) return _setting_item_from_row(row) def delete_system_setting(session: Session, key: str) -> None: normalized_key = _normalize_setting_key(key) if normalized_key in PROTECTED_SETTING_KEYS: raise ValueError("Core platform settings cannot be deleted") row = session.get(PlatformSetting, normalized_key) if row is None: raise ValueError("Setting not found") session.delete(row) session.commit() def get_platform_settings(session: Session) -> PlatformSettingsPayload: defaults = default_platform_settings() ensure_default_system_settings(session) rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all() data: Dict[str, Any] = {row.key: _read_setting_value(row) for row in rows} merged = defaults.model_dump() merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"]))) merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"]))) merged["command_auto_unlock_seconds"] = max( 1, min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])) ) merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"]) merged["allowed_attachment_extensions"] = _normalize_extension_list( data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"]) ) merged["workspace_download_extensions"] = _normalize_extension_list( data.get("workspace_download_extensions", merged["workspace_download_extensions"]) ) merged["speech_enabled"] = bool(data.get("speech_enabled", merged["speech_enabled"])) loading_page = data.get("loading_page") if isinstance(loading_page, dict): current = dict(merged["loading_page"]) for key in ("title", "subtitle", "description"): value = str(loading_page.get(key) or "").strip() if value: current[key] = value merged["loading_page"] = current return PlatformSettingsPayload.model_validate(merged) def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload: normalized = PlatformSettingsPayload( page_size=max(1, min(100, int(payload.page_size))), chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))), command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))), upload_max_mb=payload.upload_max_mb, allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions), workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions), speech_enabled=bool(payload.speech_enabled), loading_page=LoadingPageSettings.model_validate(payload.loading_page.model_dump()), ) payload_by_key = normalized.model_dump() for key in SETTING_KEYS: definition = SYSTEM_SETTING_DEFINITIONS[key] _upsert_setting_row( session, key, name=str(definition["name"]), category=str(definition["category"]), description=str(definition["description"]), value_type=str(definition["value_type"]), value=payload_by_key[key], is_public=bool(definition["is_public"]), sort_order=int(definition["sort_order"]), ) session.commit() return normalized def get_platform_settings_snapshot() -> PlatformSettingsPayload: with Session(engine) as session: return get_platform_settings(session) def get_upload_max_mb() -> int: return get_platform_settings_snapshot().upload_max_mb def get_allowed_attachment_extensions() -> List[str]: return get_platform_settings_snapshot().allowed_attachment_extensions def get_workspace_download_extensions() -> List[str]: return get_platform_settings_snapshot().workspace_download_extensions def get_page_size() -> int: return get_platform_settings_snapshot().page_size def get_chat_pull_page_size() -> int: return get_platform_settings_snapshot().chat_pull_page_size def get_speech_runtime_settings() -> Dict[str, Any]: settings = get_platform_settings_snapshot() return { "enabled": bool(settings.speech_enabled), "max_audio_seconds": int(DEFAULT_STT_MAX_AUDIO_SECONDS), "default_language": str(DEFAULT_STT_DEFAULT_LANGUAGE or "zh").strip().lower() or "zh", "force_simplified": bool(DEFAULT_STT_FORCE_SIMPLIFIED), "audio_preprocess": bool(DEFAULT_STT_AUDIO_PREPROCESS), "audio_filter": str(DEFAULT_STT_AUDIO_FILTER or "").strip(), "initial_prompt": str(DEFAULT_STT_INITIAL_PROMPT or "").strip(), "model": STT_MODEL, "device": STT_DEVICE, } def get_activity_event_retention_days(session: Session) -> int: row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY) if row is None: return DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS try: value = _read_setting_value(row) except Exception: value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS return _normalize_activity_event_retention_days(value) def create_usage_request( session: Session, bot_id: str, command: str, attachments: Optional[List[str]] = None, channel: str = "dashboard", metadata: Optional[Dict[str, Any]] = None, provider: Optional[str] = None, model: Optional[str] = None, ) -> str: request_id = uuid.uuid4().hex rows = [str(item).strip() for item in (attachments or []) if str(item).strip()] input_tokens = estimate_tokens(command) usage = BotRequestUsage( bot_id=bot_id, request_id=request_id, channel=channel, status="PENDING", provider=(str(provider or "").strip() or None), model=(str(model or "").strip() or None), token_source="estimated", input_tokens=input_tokens, output_tokens=0, total_tokens=input_tokens, input_text_preview=str(command or "")[:400], attachments_json=json.dumps(rows, ensure_ascii=False) if rows else None, metadata_json=json.dumps(metadata or {}, ensure_ascii=False), started_at=_utcnow(), created_at=_utcnow(), updated_at=_utcnow(), ) session.add(usage) session.flush() return request_id def bind_usage_message( session: Session, bot_id: str, request_id: str, message_id: Optional[int], ) -> Optional[BotRequestUsage]: if not request_id or not message_id: return None usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) if not usage_row: return None usage_row.message_id = int(message_id) usage_row.updated_at = _utcnow() session.add(usage_row) return usage_row def _find_latest_pending_usage(session: Session, bot_id: str) -> Optional[BotRequestUsage]: stmt = ( select(BotRequestUsage) .where(BotRequestUsage.bot_id == bot_id) .where(BotRequestUsage.status == "PENDING") .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) .limit(1) ) return session.exec(stmt).first() def _find_pending_usage_by_request_id(session: Session, bot_id: str, request_id: str) -> Optional[BotRequestUsage]: if not request_id: return None stmt = ( select(BotRequestUsage) .where(BotRequestUsage.bot_id == bot_id) .where(BotRequestUsage.request_id == request_id) .where(BotRequestUsage.status == "PENDING") .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) .limit(1) ) return session.exec(stmt).first() def finalize_usage_from_packet(session: Session, bot_id: str, packet: Dict[str, Any]) -> Optional[BotRequestUsage]: request_id = str(packet.get("request_id") or "").strip() usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) or _find_latest_pending_usage(session, bot_id) if not usage_row: return None raw_usage = packet.get("usage") input_tokens: Optional[int] = None output_tokens: Optional[int] = None source = "estimated" if isinstance(raw_usage, dict): for key in ("input_tokens", "prompt_tokens", "promptTokens"): if raw_usage.get(key) is not None: try: input_tokens = int(raw_usage.get(key) or 0) except Exception: input_tokens = None break for key in ("output_tokens", "completion_tokens", "completionTokens"): if raw_usage.get(key) is not None: try: output_tokens = int(raw_usage.get(key) or 0) except Exception: output_tokens = None break if input_tokens is not None or output_tokens is not None: source = "exact" text = str(packet.get("text") or packet.get("content") or "").strip() provider = str(packet.get("provider") or "").strip() model = str(packet.get("model") or "").strip() message_id = packet.get("message_id") if input_tokens is None: input_tokens = usage_row.input_tokens if output_tokens is None: output_tokens = estimate_tokens(text) if source == "exact": source = "mixed" if provider: usage_row.provider = provider[:120] if model: usage_row.model = model[:255] if message_id is not None: try: usage_row.message_id = int(message_id) except Exception: pass usage_row.output_tokens = max(0, int(output_tokens or 0)) usage_row.input_tokens = max(0, int(input_tokens or 0)) usage_row.total_tokens = usage_row.input_tokens + usage_row.output_tokens usage_row.output_text_preview = text[:400] if text else usage_row.output_text_preview usage_row.status = "COMPLETED" usage_row.token_source = source usage_row.completed_at = _utcnow() usage_row.updated_at = _utcnow() session.add(usage_row) return usage_row def fail_latest_usage(session: Session, bot_id: str, detail: str) -> Optional[BotRequestUsage]: usage_row = _find_latest_pending_usage(session, bot_id) if not usage_row: return None usage_row.status = "ERROR" usage_row.error_text = str(detail or "")[:500] usage_row.completed_at = _utcnow() usage_row.updated_at = _utcnow() session.add(usage_row) return usage_row def prune_expired_activity_events(session: Session, force: bool = False) -> int: global _last_activity_event_prune_at now = _utcnow() if not force and _last_activity_event_prune_at and now - _last_activity_event_prune_at < ACTIVITY_EVENT_PRUNE_INTERVAL: return 0 retention_days = get_activity_event_retention_days(session) cutoff = now - timedelta(days=retention_days) result = session.exec( sql_delete(BotActivityEvent).where(BotActivityEvent.created_at < cutoff) ) _last_activity_event_prune_at = now return int(getattr(result, "rowcount", 0) or 0) def record_activity_event( session: Session, bot_id: str, event_type: str, request_id: Optional[str] = None, channel: str = "dashboard", detail: Optional[str] = None, metadata: Optional[Dict[str, Any]] = None, ) -> None: normalized_event_type = str(event_type or "unknown").strip().lower() or "unknown" if normalized_event_type not in OPERATIONAL_ACTIVITY_EVENT_TYPES: return prune_expired_activity_events(session, force=False) row = BotActivityEvent( bot_id=bot_id, request_id=request_id, event_type=normalized_event_type, channel=str(channel or "dashboard").strip().lower() or "dashboard", detail=(str(detail or "").strip() or None), metadata_json=json.dumps(metadata or {}, ensure_ascii=False) if metadata else None, created_at=_utcnow(), ) session.add(row) def list_usage( session: Session, bot_id: Optional[str] = None, limit: int = 100, offset: int = 0, ) -> Dict[str, Any]: safe_limit = max(1, min(int(limit), 500)) safe_offset = max(0, int(offset or 0)) stmt = ( select(BotRequestUsage) .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) .offset(safe_offset) .limit(safe_limit) ) summary_stmt = select( func.count(BotRequestUsage.id), func.coalesce(func.sum(BotRequestUsage.input_tokens), 0), func.coalesce(func.sum(BotRequestUsage.output_tokens), 0), func.coalesce(func.sum(BotRequestUsage.total_tokens), 0), ) total_stmt = select(func.count(BotRequestUsage.id)) if bot_id: stmt = stmt.where(BotRequestUsage.bot_id == bot_id) summary_stmt = summary_stmt.where(BotRequestUsage.bot_id == bot_id) total_stmt = total_stmt.where(BotRequestUsage.bot_id == bot_id) else: since = _utcnow() - timedelta(days=1) summary_stmt = summary_stmt.where(BotRequestUsage.created_at >= since) rows = session.exec(stmt).all() count, input_sum, output_sum, total_sum = session.exec(summary_stmt).one() total = int(session.exec(total_stmt).one() or 0) items = [ PlatformUsageItem( id=int(row.id or 0), bot_id=row.bot_id, message_id=int(row.message_id) if row.message_id is not None else None, request_id=row.request_id, channel=row.channel, status=row.status, provider=row.provider, model=row.model, token_source=row.token_source, content=row.input_text_preview or row.output_text_preview, input_tokens=int(row.input_tokens or 0), output_tokens=int(row.output_tokens or 0), total_tokens=int(row.total_tokens or 0), input_text_preview=row.input_text_preview, output_text_preview=row.output_text_preview, started_at=row.started_at.isoformat() + "Z", completed_at=row.completed_at.isoformat() + "Z" if row.completed_at else None, ).model_dump() for row in rows ] return PlatformUsageResponse( summary=PlatformUsageSummary( request_count=int(count or 0), input_tokens=int(input_sum or 0), output_tokens=int(output_sum or 0), total_tokens=int(total_sum or 0), ), items=[PlatformUsageItem.model_validate(item) for item in items], total=total, limit=safe_limit, offset=safe_offset, has_more=safe_offset + len(items) < total, ).model_dump() def list_activity_events( session: Session, bot_id: Optional[str] = None, limit: int = 100, ) -> List[Dict[str, Any]]: deleted = prune_expired_activity_events(session, force=False) if deleted > 0: session.commit() safe_limit = max(1, min(int(limit), 500)) stmt = select(BotActivityEvent).order_by(BotActivityEvent.created_at.desc(), BotActivityEvent.id.desc()).limit(safe_limit) if bot_id: stmt = stmt.where(BotActivityEvent.bot_id == bot_id) rows = session.exec(stmt).all() items: List[Dict[str, Any]] = [] for row in rows: try: metadata = json.loads(row.metadata_json or "{}") except Exception: metadata = {} items.append( PlatformActivityItem( id=int(row.id or 0), bot_id=row.bot_id, request_id=row.request_id, event_type=row.event_type, channel=row.channel, detail=row.detail, metadata=metadata if isinstance(metadata, dict) else {}, created_at=row.created_at.isoformat() + "Z", ).model_dump() ) return items def build_platform_overview( session: Session, read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None, ) -> Dict[str, Any]: deleted = prune_expired_activity_events(session, force=False) if deleted > 0: session.commit() bots = session.exec(select(BotInstance)).all() images = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all() settings = get_platform_settings(session) running = 0 stopped = 0 disabled = 0 configured_cpu_total = 0.0 configured_memory_total = 0 configured_storage_total = 0 workspace_used_total = 0 workspace_limit_total = 0 live_cpu_percent_total = 0.0 live_memory_used_total = 0 live_memory_limit_total = 0 dirty = False bot_rows: List[Dict[str, Any]] = [] for bot in bots: enabled = bool(getattr(bot, "enabled", True)) resources = _read_bot_resources(bot.id) runtime_status = str(bot.docker_status or "STOPPED").upper() runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status} if callable(read_runtime): try: runtime_status, runtime = read_runtime(bot) except Exception as exc: log_edge_failure( logger, key=f"platform-overview-runtime:{bot.id}", exc=exc, message=f"Failed to read platform runtime snapshot for bot_id={bot.id}", ) runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper() runtime["docker_status"] = runtime_status if str(bot.docker_status or "").upper() != runtime_status: bot.docker_status = runtime_status session.add(bot) dirty = True if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: next_state = "IDLE" if str(bot.current_state or "") != next_state: bot.current_state = next_state session.add(bot) dirty = True workspace_used = _workspace_usage_bytes(runtime, bot.id) workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 configured_cpu_total += float(resources["cpu_cores"] or 0) configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 configured_storage_total += workspace_limit workspace_used_total += workspace_used workspace_limit_total += workspace_limit live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0) live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0) live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0) if not enabled: disabled += 1 elif runtime_status == "RUNNING": running += 1 else: stopped += 1 bot_rows.append( { "id": bot.id, "name": bot.name, "enabled": enabled, "docker_status": runtime_status, "image_tag": bot.image_tag, "llm_provider": getattr(bot, "llm_provider", None), "llm_model": getattr(bot, "llm_model", None), "current_state": bot.current_state, "last_action": bot.last_action, "resources": resources, "workspace_usage_bytes": workspace_used, "workspace_limit_bytes": workspace_limit if workspace_limit > 0 else None, } ) if dirty: session.commit() usage = list_usage(session, limit=20) events = list_activity_events(session, limit=20) return { "summary": { "bots": { "total": len(bots), "running": running, "stopped": stopped, "disabled": disabled, }, "images": { "total": len(images), "ready": len([row for row in images if row.status == "READY"]), "abnormal": len([row for row in images if row.status != "READY"]), }, "resources": { "configured_cpu_cores": round(configured_cpu_total, 2), "configured_memory_bytes": configured_memory_total, "configured_storage_bytes": configured_storage_total, "live_cpu_percent": round(live_cpu_percent_total, 2), "live_memory_used_bytes": live_memory_used_total, "live_memory_limit_bytes": live_memory_limit_total, "workspace_used_bytes": workspace_used_total, "workspace_limit_bytes": workspace_limit_total, }, }, "images": [ { "tag": row.tag, "version": row.version, "status": row.status, "source_dir": row.source_dir, "created_at": row.created_at.isoformat() + "Z", } for row in images ], "bots": bot_rows, "settings": settings.model_dump(), "usage": usage, "events": events, } def build_node_resource_overview( session: Session, *, node_id: str, read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None, ) -> Dict[str, Any]: normalized_node_id = str(node_id or "").strip().lower() bots = session.exec(select(BotInstance).where(BotInstance.node_id == normalized_node_id)).all() running = 0 stopped = 0 disabled = 0 configured_cpu_total = 0.0 configured_memory_total = 0 configured_storage_total = 0 workspace_used_total = 0 workspace_limit_total = 0 live_cpu_percent_total = 0.0 live_memory_used_total = 0 live_memory_limit_total = 0 dirty = False for bot in bots: enabled = bool(getattr(bot, "enabled", True)) resources = _read_bot_resources(bot.id) runtime_status = str(bot.docker_status or "STOPPED").upper() runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status} if callable(read_runtime): try: runtime_status, runtime = read_runtime(bot) except Exception as exc: log_edge_failure( logger, key=f"platform-node-runtime:{normalized_node_id}:{bot.id}", exc=exc, message=f"Failed to read node runtime snapshot for bot_id={bot.id}", ) runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper() runtime["docker_status"] = runtime_status if str(bot.docker_status or "").upper() != runtime_status: bot.docker_status = runtime_status session.add(bot) dirty = True workspace_used = _workspace_usage_bytes(runtime, bot.id) workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 configured_cpu_total += float(resources["cpu_cores"] or 0) configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 configured_storage_total += workspace_limit workspace_used_total += workspace_used workspace_limit_total += workspace_limit live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0) live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0) live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0) if not enabled: disabled += 1 elif runtime_status == "RUNNING": running += 1 else: stopped += 1 if dirty: session.commit() return { "node_id": normalized_node_id, "bots": { "total": len(bots), "running": running, "stopped": stopped, "disabled": disabled, }, "resources": { "configured_cpu_cores": round(configured_cpu_total, 2), "configured_memory_bytes": configured_memory_total, "configured_storage_bytes": configured_storage_total, "live_cpu_percent": round(live_cpu_percent_total, 2), "live_memory_used_bytes": live_memory_used_total, "live_memory_limit_bytes": live_memory_limit_total, "workspace_used_bytes": workspace_used_total, "workspace_limit_bytes": workspace_limit_total, }, }