dashboard-nanobot/backend/services/platform_service.py

1066 lines
38 KiB
Python

import json
import math
import os
import re
import uuid
from datetime import datetime, timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import delete as sql_delete, func
from sqlmodel import Session, select
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_started",
"bot_stopped",
"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
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 _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, docker_manager: Any) -> 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
bot_rows: List[Dict[str, Any]] = []
for bot in bots:
enabled = bool(getattr(bot, "enabled", True))
runtime_status = docker_manager.get_bot_status(bot.id) if docker_manager else str(bot.docker_status or "STOPPED")
resources = _read_bot_resources(bot.id)
runtime = docker_manager.get_bot_resource_snapshot(bot.id) if docker_manager else {"usage": {}, "limits": {}, "docker_status": runtime_status}
workspace_root = _bot_workspace_root(bot.id)
workspace_used = _calc_dir_size_bytes(workspace_root)
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,
}
)
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,
}