dev
mula.liu 2026-03-27 00:12:46 +08:00
parent b8ca934bd1
commit f20dabc58e
180 changed files with 39841 additions and 20732 deletions

View File

@ -0,0 +1,124 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, File, Form, UploadFile
from sqlmodel import Session
from core.database import get_session
from models.bot import NanobotImage
from api.dashboard_router_support import DashboardRouterDeps
def build_dashboard_assets_router(*, deps: DashboardRouterDeps) -> APIRouter:
router = APIRouter()
@router.get("/api/images", response_model=List[NanobotImage])
def list_images(session: Session = Depends(get_session)):
return deps.image_service.list_images(session=session)
@router.delete("/api/images/{tag:path}")
def delete_image(tag: str, session: Session = Depends(get_session)):
return deps.image_service.delete_image(session=session, tag=tag)
@router.get("/api/docker-images")
def list_docker_images(repository: str = "nanobot-base"):
return deps.image_service.list_docker_images(repository=repository)
@router.post("/api/images/register")
def register_image(payload: dict, session: Session = Depends(get_session)):
return deps.image_service.register_image(session=session, payload=payload)
@router.post("/api/providers/test")
async def test_provider(payload: dict):
return await deps.provider_test_service.test_provider(payload=payload)
@router.get("/api/platform/skills")
def list_skill_market(session: Session = Depends(get_session)):
return deps.skill_service.list_market_items(session=session)
@router.post("/api/platform/skills")
async def create_skill_market_item(
skill_key: str = Form(""),
display_name: str = Form(""),
description: str = Form(""),
file: UploadFile = File(...),
session: Session = Depends(get_session),
):
return await deps.skill_service.create_market_item(
session=session,
skill_key=skill_key,
display_name=display_name,
description=description,
file=file,
)
@router.put("/api/platform/skills/{skill_id}")
async def update_skill_market_item(
skill_id: int,
skill_key: str = Form(""),
display_name: str = Form(""),
description: str = Form(""),
file: Optional[UploadFile] = File(None),
session: Session = Depends(get_session),
):
return await deps.skill_service.update_market_item(
session=session,
skill_id=skill_id,
skill_key=skill_key,
display_name=display_name,
description=description,
file=file,
)
@router.delete("/api/platform/skills/{skill_id}")
def delete_skill_market_item(skill_id: int, session: Session = Depends(get_session)):
return deps.skill_service.delete_market_item(session=session, skill_id=skill_id)
@router.get("/api/bots/{bot_id}/skills")
def list_bot_skills(bot_id: str, session: Session = Depends(get_session)):
return deps.skill_service.list_workspace_skills_for_bot(
session=session,
bot_id=bot_id,
resolve_edge_state_context=deps.resolve_edge_state_context,
logger=deps.logger,
)
@router.get("/api/bots/{bot_id}/skill-market")
def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)):
return deps.skill_service.list_bot_market_items_for_bot(
session=session,
bot_id=bot_id,
resolve_edge_state_context=deps.resolve_edge_state_context,
logger=deps.logger,
)
@router.post("/api/bots/{bot_id}/skill-market/{skill_id}/install")
def install_bot_skill_from_market(bot_id: str, skill_id: int, session: Session = Depends(get_session)):
return deps.skill_service.install_market_item_for_bot_checked(
session=session,
bot_id=bot_id,
skill_id=skill_id,
resolve_edge_state_context=deps.resolve_edge_state_context,
logger=deps.logger,
)
@router.post("/api/bots/{bot_id}/skills/upload")
async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), session: Session = Depends(get_session)):
return await deps.skill_service.upload_bot_skill_zip_for_bot(
session=session,
bot_id=bot_id,
file=file,
resolve_edge_state_context=deps.resolve_edge_state_context,
logger=deps.logger,
)
@router.delete("/api/bots/{bot_id}/skills/{skill_name}")
def delete_bot_skill(bot_id: str, skill_name: str, session: Session = Depends(get_session)):
return deps.skill_service.delete_workspace_skill_for_bot(
session=session,
bot_id=bot_id,
skill_name=skill_name,
resolve_edge_state_context=deps.resolve_edge_state_context,
)
return router

View File

@ -0,0 +1,153 @@
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from core.database import get_session
from schemas.dashboard import (
BotCreateRequest,
BotDeployRequest,
BotEnvParamsUpdateRequest,
BotMcpConfigUpdateRequest,
BotToolsConfigUpdateRequest,
BotUpdateRequest,
ChannelConfigRequest,
ChannelConfigUpdateRequest,
)
from api.dashboard_router_support import DashboardRouterDeps
def build_dashboard_bot_admin_router(*, deps: DashboardRouterDeps) -> APIRouter:
router = APIRouter()
@router.post("/api/bots")
def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.create_bot(session=session, payload=payload)
@router.get("/api/bots")
def list_bots(request: Request, session: Session = Depends(get_session)):
current_user_id = int(getattr(request.state, "sys_user_id", 0) or 0)
return deps.bot_query_service.list_bots(app_state=request.app.state, session=session, current_user_id=current_user_id)
@router.get("/api/bots/{bot_id}")
def get_bot_detail(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.bot_query_service.get_bot_detail(app_state=request.app.state, session=session, bot_id=bot_id)
@router.get("/api/bots/{bot_id}/resources")
def get_bot_resources(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.bot_query_service.get_bot_resources(app_state=request.app.state, session=session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}")
def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.update_bot(session=session, bot_id=bot_id, payload=payload)
@router.post("/api/bots/{bot_id}/deploy")
async def deploy_bot(bot_id: str, payload: BotDeployRequest, request: Request, session: Session = Depends(get_session)):
return await deps.bot_lifecycle_service.deploy_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
node_id=payload.node_id,
runtime_kind=payload.runtime_kind,
image_tag=payload.image_tag,
auto_start=bool(payload.auto_start),
)
@router.post("/api/bots/{bot_id}/start")
async def start_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
return await deps.bot_lifecycle_service.start_bot(app_state=request.app.state, session=session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/stop")
def stop_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.stop_bot(app_state=request.app.state, session=session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/enable")
def enable_bot(bot_id: str, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.enable_bot(session=session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/disable")
def disable_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.disable_bot(app_state=request.app.state, session=session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/deactivate")
def deactivate_bot(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.deactivate_bot(app_state=request.app.state, session=session, bot_id=bot_id)
@router.delete("/api/bots/{bot_id}")
def delete_bot(bot_id: str, request: Request, delete_workspace: bool = True, session: Session = Depends(get_session)):
return deps.bot_lifecycle_service.delete_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
delete_workspace=delete_workspace,
)
@router.get("/api/bots/{bot_id}/channels")
def list_bot_channels(bot_id: str, session: Session = Depends(get_session)):
return deps.bot_channel_service.list_channels(session=session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/channels")
def create_bot_channel(bot_id: str, payload: ChannelConfigRequest, session: Session = Depends(get_session)):
return deps.bot_channel_service.create_channel(session=session, bot_id=bot_id, payload=payload)
@router.put("/api/bots/{bot_id}/channels/{channel_id}")
def update_bot_channel(bot_id: str, channel_id: str, payload: ChannelConfigUpdateRequest, session: Session = Depends(get_session)):
return deps.bot_channel_service.update_channel(
session=session,
bot_id=bot_id,
channel_id=channel_id,
payload=payload,
)
@router.delete("/api/bots/{bot_id}/channels/{channel_id}")
def delete_bot_channel(bot_id: str, channel_id: str, session: Session = Depends(get_session)):
return deps.bot_channel_service.delete_channel(session=session, bot_id=bot_id, channel_id=channel_id)
@router.get("/api/bots/{bot_id}/tools-config")
def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)):
return deps.bot_query_service.get_tools_config(session=session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}/tools-config")
def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, session: Session = Depends(get_session)):
return deps.bot_query_service.update_tools_config(session=session, bot_id=bot_id, payload=payload)
@router.get("/api/bots/{bot_id}/mcp-config")
def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)):
return deps.bot_config_state_service.get_mcp_config_for_bot(session=session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}/mcp-config")
def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, session: Session = Depends(get_session)):
return deps.bot_config_state_service.update_mcp_config_for_bot(
session=session,
bot_id=bot_id,
mcp_servers=payload.mcp_servers,
)
@router.get("/api/bots/{bot_id}/env-params")
def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)):
return deps.bot_config_state_service.get_env_params_for_bot(session=session, bot_id=bot_id)
@router.put("/api/bots/{bot_id}/env-params")
def update_bot_env_params(bot_id: str, payload: BotEnvParamsUpdateRequest, session: Session = Depends(get_session)):
return deps.bot_config_state_service.update_env_params_for_bot(
session=session,
bot_id=bot_id,
env_params=payload.env_params,
)
@router.get("/api/bots/{bot_id}/cron/jobs")
def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)):
return deps.bot_config_state_service.list_cron_jobs_for_bot(
session=session,
bot_id=bot_id,
include_disabled=include_disabled,
)
@router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/stop")
def stop_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
return deps.bot_config_state_service.stop_cron_job_for_bot(session=session, bot_id=bot_id, job_id=job_id)
@router.delete("/api/bots/{bot_id}/cron/jobs/{job_id}")
def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)):
return deps.bot_config_state_service.delete_cron_job_for_bot(session=session, bot_id=bot_id, job_id=job_id)
return router

View File

@ -0,0 +1,197 @@
from typing import List, Optional
from fastapi import APIRouter, Depends, File, Form, Request, UploadFile, WebSocket
from sqlmodel import Session
from core.database import get_session
from schemas.dashboard import (
CommandRequest,
MessageFeedbackRequest,
WorkspaceFileUpdateRequest,
)
from api.dashboard_router_support import DashboardRouterDeps
def build_dashboard_bot_io_router(*, deps: DashboardRouterDeps) -> APIRouter:
router = APIRouter()
@router.post("/api/bots/{bot_id}/command")
def send_command(bot_id: str, payload: CommandRequest, request: Request, session: Session = Depends(get_session)):
return deps.runtime_service.send_command_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
payload=payload,
)
@router.get("/api/bots/{bot_id}/messages")
def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(get_session)):
return deps.bot_message_service.list_messages(session=session, bot_id=bot_id, limit=limit)
@router.get("/api/bots/{bot_id}/messages/page")
def list_bot_messages_page(bot_id: str, limit: Optional[int] = None, before_id: Optional[int] = None, session: Session = Depends(get_session)):
return deps.bot_message_service.list_messages_page(
session=session,
bot_id=bot_id,
limit=limit,
before_id=before_id,
)
@router.get("/api/bots/{bot_id}/messages/by-date")
def list_bot_messages_by_date(
bot_id: str,
date: str,
tz_offset_minutes: Optional[int] = None,
limit: Optional[int] = None,
session: Session = Depends(get_session),
):
return deps.bot_message_service.list_messages_by_date(
session=session,
bot_id=bot_id,
date=date,
tz_offset_minutes=tz_offset_minutes,
limit=limit,
)
@router.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
def update_bot_message_feedback(bot_id: str, message_id: int, payload: MessageFeedbackRequest, session: Session = Depends(get_session)):
return deps.bot_message_service.update_feedback(
session=session,
bot_id=bot_id,
message_id=message_id,
feedback=payload.feedback,
)
@router.delete("/api/bots/{bot_id}/messages")
def clear_bot_messages(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.runtime_service.clear_messages_for_bot(app_state=request.app.state, session=session, bot_id=bot_id)
@router.post("/api/bots/{bot_id}/sessions/dashboard-direct/clear")
def clear_bot_dashboard_direct_session(bot_id: str, request: Request, session: Session = Depends(get_session)):
return deps.runtime_service.clear_dashboard_direct_session_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
)
@router.get("/api/bots/{bot_id}/logs")
def get_bot_logs(bot_id: str, tail: int = 300, request: Request = None, session: Session = Depends(get_session)):
return deps.runtime_service.get_logs_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
tail=tail,
)
@router.get("/api/bots/{bot_id}/workspace/tree")
def get_workspace_tree(bot_id: str, path: Optional[str] = None, recursive: bool = False, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.list_tree_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
recursive=recursive,
)
@router.get("/api/bots/{bot_id}/workspace/file")
def read_workspace_file(bot_id: str, path: str, max_bytes: int = 200000, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.read_file_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
max_bytes=max_bytes,
)
@router.put("/api/bots/{bot_id}/workspace/file")
def update_workspace_file(bot_id: str, path: str, payload: WorkspaceFileUpdateRequest, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.write_markdown_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
content=str(payload.content or ""),
)
@router.get("/api/bots/{bot_id}/workspace/download")
def download_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.serve_file_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
download=download,
request=request,
public=False,
redirect_html_to_raw=True,
)
@router.get("/public/bots/{bot_id}/workspace/download")
def public_download_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.serve_file_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
download=download,
request=request,
public=True,
redirect_html_to_raw=True,
)
@router.get("/api/bots/{bot_id}/workspace/raw/{path:path}")
def raw_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.serve_file_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
download=download,
request=request,
public=False,
redirect_html_to_raw=False,
)
@router.get("/public/bots/{bot_id}/workspace/raw/{path:path}")
def public_raw_workspace_file(bot_id: str, path: str, download: bool = False, request: Request = None, session: Session = Depends(get_session)):
return deps.workspace_service.serve_file_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
path=path,
download=download,
request=request,
public=True,
redirect_html_to_raw=False,
)
@router.post("/api/bots/{bot_id}/workspace/upload")
async def upload_workspace_files(bot_id: str, files: List[UploadFile] = File(...), path: Optional[str] = None, request: Request = None, session: Session = Depends(get_session)):
return await deps.workspace_service.upload_files_for_bot(
app_state=request.app.state,
session=session,
bot_id=bot_id,
files=files,
path=path,
)
@router.post("/api/bots/{bot_id}/speech/transcribe")
async def transcribe_bot_speech(
bot_id: str,
file: UploadFile = File(...),
language: Optional[str] = Form(None),
session: Session = Depends(get_session),
):
return await deps.speech_transcription_service.transcribe(
session=session,
bot_id=bot_id,
file=file,
language=language,
)
@router.websocket("/ws/monitor/{bot_id}")
async def websocket_endpoint(websocket: WebSocket, bot_id: str):
await deps.app_lifecycle_service.handle_websocket(websocket, bot_id)
return router

View File

@ -0,0 +1,46 @@
from fastapi import APIRouter
from api.dashboard_assets_router import build_dashboard_assets_router
from api.dashboard_bot_admin_router import build_dashboard_bot_admin_router
from api.dashboard_bot_io_router import build_dashboard_bot_io_router
from api.dashboard_router_support import DashboardRouterDeps
def build_dashboard_router(
*,
image_service,
provider_test_service,
bot_lifecycle_service,
bot_query_service,
bot_channel_service,
skill_service,
bot_config_state_service,
runtime_service,
bot_message_service,
workspace_service,
speech_transcription_service,
app_lifecycle_service,
resolve_edge_state_context,
logger,
) -> APIRouter:
deps = DashboardRouterDeps(
image_service=image_service,
provider_test_service=provider_test_service,
bot_lifecycle_service=bot_lifecycle_service,
bot_query_service=bot_query_service,
bot_channel_service=bot_channel_service,
skill_service=skill_service,
bot_config_state_service=bot_config_state_service,
runtime_service=runtime_service,
bot_message_service=bot_message_service,
workspace_service=workspace_service,
speech_transcription_service=speech_transcription_service,
app_lifecycle_service=app_lifecycle_service,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
router = APIRouter()
router.include_router(build_dashboard_assets_router(deps=deps))
router.include_router(build_dashboard_bot_admin_router(deps=deps))
router.include_router(build_dashboard_bot_io_router(deps=deps))
return router

View File

@ -0,0 +1,20 @@
from dataclasses import dataclass
from typing import Any, Callable
@dataclass(frozen=True)
class DashboardRouterDeps:
image_service: Any
provider_test_service: Any
bot_lifecycle_service: Any
bot_query_service: Any
bot_channel_service: Any
skill_service: Any
bot_config_state_service: Any
runtime_service: Any
bot_message_service: Any
workspace_service: Any
speech_transcription_service: Any
app_lifecycle_service: Any
resolve_edge_state_context: Callable[[str], Any]
logger: Any

View File

@ -0,0 +1,8 @@
from fastapi import APIRouter
from api.platform_overview_router import router as platform_overview_router
from api.platform_settings_router import router as platform_settings_router
router = APIRouter()
router.include_router(platform_overview_router)
router.include_router(platform_settings_router)

View File

@ -0,0 +1,159 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from core.database import get_session
from models.bot import BotInstance
from providers.target import ProviderTarget
from services.node_registry_service import ManagedNode
from api.platform_node_support import (
edge_node_self_with_native_preflight,
managed_node_from_payload,
normalize_node_payload,
serialize_node,
)
from api.platform_shared import (
cached_platform_nodes_payload,
invalidate_platform_nodes_cache,
invalidate_platform_overview_cache,
logger,
store_platform_nodes_payload,
)
from clients.edge.errors import log_edge_failure
from schemas.platform import ManagedNodePayload
router = APIRouter()
@router.get("/api/platform/nodes")
def list_platform_nodes(request: Request, session: Session = Depends(get_session)):
cached_payload = cached_platform_nodes_payload()
if cached_payload is not None:
return cached_payload
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "list_nodes"):
return {"items": []}
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
refreshed_items = []
for node in node_registry.list_nodes():
metadata = dict(node.metadata or {})
refresh_failed = False
if callable(resolve_edge_client) and str(metadata.get("transport_kind") or "").strip().lower() == "edge" and bool(node.enabled):
try:
client = resolve_edge_client(
ProviderTarget(
node_id=node.node_id,
transport_kind="edge",
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
)
)
node_self = edge_node_self_with_native_preflight(client=client, node=node)
node = node_registry.mark_node_seen(
session,
node_id=node.node_id,
display_name=str(node.display_name or node_self.get("display_name") or node.node_id),
capabilities=dict(node_self.get("capabilities") or {}),
resources=dict(node_self.get("resources") or {}),
)
except Exception as exc:
refresh_failed = True
log_edge_failure(
logger,
key=f"platform-node-refresh:{node.node_id}",
exc=exc,
message=f"Failed to refresh edge node metadata for node_id={node.node_id}",
)
refreshed_items.append((node, refresh_failed))
return store_platform_nodes_payload([
serialize_node(node, refresh_failed=refresh_failed)
for node, refresh_failed in refreshed_items
])
@router.get("/api/platform/nodes/{node_id}")
def get_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
node = node_registry.get_node(normalized_node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
return serialize_node(node)
@router.post("/api/platform/nodes")
def create_platform_node(payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
normalized = normalize_node_payload(payload)
if node_registry.get_node(normalized.node_id) is not None:
raise HTTPException(status_code=409, detail=f"Node already exists: {normalized.node_id}")
node = node_registry.upsert_node(session, managed_node_from_payload(normalized))
invalidate_platform_overview_cache()
invalidate_platform_nodes_cache()
return serialize_node(node)
@router.put("/api/platform/nodes/{node_id}")
def update_platform_node(node_id: str, payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
existing = node_registry.get_node(normalized_node_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
normalized = normalize_node_payload(payload)
if normalized.node_id != normalized_node_id:
raise HTTPException(status_code=400, detail="node_id cannot be changed")
node = node_registry.upsert_node(
session,
ManagedNode(
node_id=normalized_node_id,
display_name=normalized.display_name,
base_url=normalized.base_url,
enabled=bool(normalized.enabled),
auth_token=normalized.auth_token or existing.auth_token,
metadata={
"transport_kind": normalized.transport_kind,
"runtime_kind": normalized.runtime_kind,
"core_adapter": normalized.core_adapter,
"workspace_root": normalized.workspace_root,
"native_command": normalized.native_command,
"native_workdir": normalized.native_workdir,
"native_sandbox_mode": normalized.native_sandbox_mode,
},
capabilities=dict(existing.capabilities or {}),
resources=dict(existing.resources or {}),
last_seen_at=existing.last_seen_at,
),
)
invalidate_platform_overview_cache()
invalidate_platform_nodes_cache()
return serialize_node(node)
@router.delete("/api/platform/nodes/{node_id}")
def delete_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
if normalized_node_id == "local":
raise HTTPException(status_code=400, detail="Local node cannot be deleted")
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
if node_registry.get_node(normalized_node_id) is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
attached_bot_ids = session.exec(select(BotInstance.id).where(BotInstance.node_id == normalized_node_id)).all()
if attached_bot_ids:
raise HTTPException(
status_code=400,
detail=f"Node {normalized_node_id} still has bots assigned: {', '.join(str(item) for item in attached_bot_ids[:5])}",
)
node_registry.delete_node(session, normalized_node_id)
invalidate_platform_overview_cache()
invalidate_platform_nodes_cache()
return {"status": "deleted", "node_id": normalized_node_id}

View File

@ -0,0 +1,119 @@
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session
from clients.edge.http import HttpEdgeClient
from core.database import get_session
from schemas.platform import ManagedNodePayload
from api.platform_node_support import (
managed_node_from_payload,
normalize_node_payload,
test_edge_connectivity,
test_edge_native_preflight,
)
from api.platform_shared import invalidate_platform_nodes_cache
router = APIRouter()
@router.post("/api/platform/nodes/test")
def test_platform_node(payload: ManagedNodePayload, request: Request):
normalized = normalize_node_payload(payload)
temp_node = managed_node_from_payload(normalized)
result = test_edge_connectivity(
lambda _target: HttpEdgeClient(
node=temp_node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
temp_node,
)
return result.model_dump()
@router.post("/api/platform/nodes/native/preflight")
def test_platform_node_native_preflight(payload: ManagedNodePayload, request: Request):
normalized = normalize_node_payload(payload)
temp_node = managed_node_from_payload(normalized)
result = test_edge_native_preflight(
lambda _target: HttpEdgeClient(
node=temp_node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
temp_node,
native_command=str(normalized.native_command or "").strip() or None,
native_workdir=str(normalized.native_workdir or "").strip() or None,
)
return result.model_dump()
@router.post("/api/platform/nodes/{node_id}/test")
def test_saved_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
node = node_registry.get_node(normalized_node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
if transport_kind != "edge":
invalidate_platform_nodes_cache()
raise HTTPException(status_code=400, detail="Only edge transport is supported")
result = test_edge_connectivity(
lambda _target: HttpEdgeClient(
node=node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
node,
)
if result.ok:
node_registry.mark_node_seen(
session,
node_id=node.node_id,
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id) if result.node_self else node.display_name,
capabilities=dict(result.node_self.get("capabilities") or {}) if result.node_self else dict(node.capabilities or {}),
resources=dict(result.node_self.get("resources") or {}) if result.node_self else dict(getattr(node, "resources", {}) or {}),
)
invalidate_platform_nodes_cache()
return result.model_dump()
@router.post("/api/platform/nodes/{node_id}/native/preflight")
def test_saved_platform_node_native_preflight(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
node = node_registry.get_node(normalized_node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
if transport_kind != "edge":
invalidate_platform_nodes_cache()
raise HTTPException(status_code=400, detail="Only edge transport is supported")
metadata = dict(node.metadata or {})
result = test_edge_native_preflight(
lambda _target: HttpEdgeClient(
node=node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
node,
native_command=str(metadata.get("native_command") or "").strip() or None,
native_workdir=str(metadata.get("native_workdir") or "").strip() or None,
)
if result.status == "online" and result.node_self:
node_registry.mark_node_seen(
session,
node_id=node.node_id,
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id),
capabilities=dict(result.node_self.get("capabilities") or {}),
resources=dict(result.node_self.get("resources") or {}),
)
invalidate_platform_nodes_cache()
return result.model_dump()

View File

@ -0,0 +1,57 @@
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from clients.edge.errors import log_edge_failure
from core.database import get_session
from providers.selector import get_runtime_provider
from providers.target import ProviderTarget
from services.platform_overview_service import build_node_resource_overview
from api.platform_shared import logger
router = APIRouter()
@router.get("/api/platform/nodes/{node_id}/resources")
def get_platform_node_resources(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is not None and hasattr(node_registry, "get_node"):
node = node_registry.get_node(normalized_node_id)
if node is not None:
metadata = dict(getattr(node, "metadata", {}) or {})
if str(metadata.get("transport_kind") or "").strip().lower() == "edge":
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
if callable(resolve_edge_client):
base = build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=None)
client = resolve_edge_client(
ProviderTarget(
node_id=normalized_node_id,
transport_kind="edge",
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
)
)
try:
resource_report = dict(client.get_node_resources() or {})
except Exception as exc:
log_edge_failure(
logger,
key=f"platform-node-resources:{normalized_node_id}",
exc=exc,
message=f"Failed to load edge node resources for node_id={normalized_node_id}",
)
return base
base["resources"] = dict(resource_report.get("resources") or resource_report)
if resource_report:
base["node_report"] = resource_report
return base
def _read_runtime(bot):
provider = get_runtime_provider(request.app.state, bot)
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
runtime.setdefault("docker_status", status)
return status, runtime
return build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=_read_runtime)

View File

@ -0,0 +1,251 @@
import shlex
import time
from typing import Any, Dict, Optional
import httpx
from fastapi import HTTPException
from clients.edge.errors import log_edge_failure, summarize_edge_exception
from clients.edge.http import HttpEdgeClient
from providers.target import ProviderTarget
from schemas.platform import (
ManagedNodeConnectivityResult,
ManagedNodeNativePreflightResult,
ManagedNodePayload,
)
from services.node_registry_service import ManagedNode
from api.platform_shared import logger
def normalize_native_sandbox_mode(raw_value: Any) -> str:
text = str(raw_value or "").strip().lower()
if text in {"workspace", "sandbox", "strict"}:
return "workspace"
if text in {"full_access", "full-access", "danger-full-access", "escape"}:
return "full_access"
return "inherit"
def normalize_node_payload(payload: ManagedNodePayload) -> ManagedNodePayload:
normalized_node_id = str(payload.node_id or "").strip().lower()
if not normalized_node_id:
raise HTTPException(status_code=400, detail="node_id is required")
transport_kind = str(payload.transport_kind or "edge").strip().lower() or "edge"
if transport_kind != "edge":
raise HTTPException(status_code=400, detail="Only edge transport is supported")
runtime_kind = str(payload.runtime_kind or "docker").strip().lower() or "docker"
core_adapter = str(payload.core_adapter or "nanobot").strip().lower() or "nanobot"
native_sandbox_mode = normalize_native_sandbox_mode(payload.native_sandbox_mode)
base_url = str(payload.base_url or "").strip()
if transport_kind == "edge" and not base_url:
raise HTTPException(status_code=400, detail="base_url is required for edge nodes")
return payload.model_copy(
update={
"node_id": normalized_node_id,
"display_name": str(payload.display_name or normalized_node_id).strip() or normalized_node_id,
"base_url": base_url,
"auth_token": str(payload.auth_token or "").strip(),
"transport_kind": transport_kind,
"runtime_kind": runtime_kind,
"core_adapter": core_adapter,
"workspace_root": str(payload.workspace_root or "").strip(),
"native_command": str(payload.native_command or "").strip(),
"native_workdir": str(payload.native_workdir or "").strip(),
"native_sandbox_mode": native_sandbox_mode,
}
)
def managed_node_from_payload(payload: ManagedNodePayload) -> ManagedNode:
normalized = normalize_node_payload(payload)
return ManagedNode(
node_id=normalized.node_id,
display_name=normalized.display_name,
base_url=normalized.base_url,
enabled=bool(normalized.enabled),
auth_token=normalized.auth_token,
metadata={
"transport_kind": normalized.transport_kind,
"runtime_kind": normalized.runtime_kind,
"core_adapter": normalized.core_adapter,
"workspace_root": normalized.workspace_root,
"native_command": normalized.native_command,
"native_workdir": normalized.native_workdir,
"native_sandbox_mode": normalized.native_sandbox_mode,
},
)
def node_status(node: ManagedNode, *, refresh_failed: bool = False) -> str:
if not bool(node.enabled):
return "disabled"
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
if transport_kind != "edge":
return "unknown"
if refresh_failed:
return "offline"
return "online" if node.last_seen_at else "unknown"
def serialize_node(node: ManagedNode, *, refresh_failed: bool = False) -> Dict[str, Any]:
metadata = dict(node.metadata or {})
return {
"node_id": node.node_id,
"display_name": node.display_name,
"base_url": node.base_url,
"enabled": bool(node.enabled),
"transport_kind": str(metadata.get("transport_kind") or ""),
"runtime_kind": str(metadata.get("runtime_kind") or ""),
"core_adapter": str(metadata.get("core_adapter") or ""),
"workspace_root": str(metadata.get("workspace_root") or ""),
"native_command": str(metadata.get("native_command") or ""),
"native_workdir": str(metadata.get("native_workdir") or ""),
"native_sandbox_mode": str(metadata.get("native_sandbox_mode") or "inherit"),
"metadata": metadata,
"capabilities": dict(node.capabilities or {}),
"resources": dict(getattr(node, "resources", {}) or {}),
"last_seen_at": node.last_seen_at,
"status": node_status(node, refresh_failed=refresh_failed),
}
def split_native_command(raw_command: Optional[str]) -> list[str]:
text = str(raw_command or "").strip()
if not text:
return []
try:
return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()]
except Exception:
return [text]
def runtime_native_supported(node_self: Dict[str, Any]) -> bool:
capabilities = dict(node_self.get("capabilities") or {})
runtime_caps = dict(capabilities.get("runtime") or {})
return bool(runtime_caps.get("native") is True)
def edge_node_self_with_native_preflight(*, client: HttpEdgeClient, node: ManagedNode) -> Dict[str, Any]:
node_self = dict(client.heartbeat_node() or {})
metadata = dict(node.metadata or {})
native_command = str(metadata.get("native_command") or "").strip() or None
native_workdir = str(metadata.get("native_workdir") or "").strip() or None
runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower()
should_probe = bool(native_command or native_workdir or runtime_kind == "native")
if not should_probe:
return node_self
try:
preflight = dict(client.preflight_native(native_command=native_command, native_workdir=native_workdir) or {})
except Exception as exc:
log_edge_failure(
logger,
key=f"platform-node-native-preflight:{node.node_id}",
exc=exc,
message=f"Failed to run native preflight for node_id={node.node_id}",
)
return node_self
caps = dict(node_self.get("capabilities") or {})
process_caps = dict(caps.get("process") or {})
if preflight.get("command"):
process_caps["command"] = list(preflight.get("command") or [])
process_caps["available"] = bool(preflight.get("ok"))
process_caps["command_available"] = bool(preflight.get("command_available"))
process_caps["workdir_exists"] = bool(preflight.get("workdir_exists"))
process_caps["workdir"] = str(preflight.get("workdir") or "")
process_caps["detail"] = str(preflight.get("detail") or "")
caps["process"] = process_caps
node_self["capabilities"] = caps
node_self["native_preflight"] = preflight
return node_self
def test_edge_connectivity(resolve_edge_client, node: ManagedNode) -> ManagedNodeConnectivityResult:
started = time.perf_counter()
try:
client = resolve_edge_client(
ProviderTarget(
node_id=node.node_id,
transport_kind="edge",
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
)
)
node_self = edge_node_self_with_native_preflight(client=client, node=node)
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
return ManagedNodeConnectivityResult(
ok=True,
status="online",
latency_ms=latency_ms,
detail="dashboard-edge reachable",
node_self=node_self,
)
except Exception as exc:
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
return ManagedNodeConnectivityResult(
ok=False,
status="offline",
latency_ms=latency_ms,
detail=summarize_edge_exception(exc),
node_self=None,
)
def test_edge_native_preflight(
resolve_edge_client,
node: ManagedNode,
*,
native_command: Optional[str] = None,
native_workdir: Optional[str] = None,
) -> ManagedNodeNativePreflightResult:
started = time.perf_counter()
command_hint = split_native_command(native_command)
workdir_hint = str(native_workdir or "").strip()
try:
client = resolve_edge_client(
ProviderTarget(
node_id=node.node_id,
transport_kind="edge",
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
)
)
node_self = dict(client.heartbeat_node() or {})
preflight = dict(
client.preflight_native(
native_command=native_command,
native_workdir=native_workdir,
) or {}
)
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
command = [str(item or "").strip() for item in list(preflight.get("command") or []) if str(item or "").strip()]
workdir = str(preflight.get("workdir") or "")
detail = str(preflight.get("detail") or "")
if not detail:
detail = "native launcher ready" if bool(preflight.get("ok")) else "native launcher not ready"
return ManagedNodeNativePreflightResult(
ok=bool(preflight.get("ok")),
status="online",
latency_ms=latency_ms,
detail=detail,
command=command,
workdir=workdir,
command_available=bool(preflight.get("command_available")),
workdir_exists=bool(preflight.get("workdir_exists")),
runtime_native_supported=runtime_native_supported(node_self),
node_self=node_self,
)
except Exception as exc:
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
return ManagedNodeNativePreflightResult(
ok=False,
status="offline",
latency_ms=latency_ms,
detail=summarize_edge_exception(exc),
command=command_hint,
workdir=workdir_hint,
command_available=False,
workdir_exists=False if workdir_hint else True,
runtime_native_supported=False,
node_self=None,
)

View File

@ -0,0 +1,11 @@
from fastapi import APIRouter
from api.platform_node_catalog_router import router as platform_node_catalog_router
from api.platform_node_probe_router import router as platform_node_probe_router
from api.platform_node_resource_router import router as platform_node_resource_router
router = APIRouter()
router.include_router(platform_node_catalog_router)
router.include_router(platform_node_probe_router)
router.include_router(platform_node_resource_router)

View File

@ -0,0 +1,79 @@
from typing import Optional
from fastapi import APIRouter, Depends, Request
from sqlmodel import Session
from api.platform_shared import (
apply_platform_runtime_changes,
cached_platform_overview_payload,
invalidate_platform_nodes_cache,
invalidate_platform_overview_cache,
store_platform_overview_payload,
)
from core.database import get_session
from providers.selector import get_runtime_provider
from services.platform_activity_service import list_activity_events
from services.platform_analytics_service import build_dashboard_analytics
from services.platform_overview_service import build_platform_overview
from services.platform_usage_service import list_usage
router = APIRouter()
@router.get("/api/platform/overview")
def get_platform_overview(request: Request, session: Session = Depends(get_session)):
cached_payload = cached_platform_overview_payload()
if cached_payload is not None:
return cached_payload
def _read_runtime(bot):
provider = get_runtime_provider(request.app.state, bot)
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
runtime.setdefault("docker_status", status)
return status, runtime
payload = build_platform_overview(session, read_runtime=_read_runtime)
return store_platform_overview_payload(payload)
@router.post("/api/platform/cache/clear")
def clear_platform_cache():
invalidate_platform_overview_cache()
invalidate_platform_nodes_cache()
return {"status": "cleared"}
@router.post("/api/platform/reload")
def reload_platform_runtime(request: Request):
apply_platform_runtime_changes(request)
return {"status": "reloaded"}
@router.get("/api/platform/usage")
def get_platform_usage(
bot_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
):
return list_usage(session, bot_id=bot_id, limit=limit, offset=offset)
@router.get("/api/platform/dashboard-analytics")
def get_platform_dashboard_analytics(
since_days: int = 7,
events_limit: int = 20,
session: Session = Depends(get_session),
):
return build_dashboard_analytics(session, since_days=since_days, events_limit=events_limit)
@router.get("/api/platform/events")
def get_platform_events(
bot_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
):
return list_activity_events(session, bot_id=bot_id, limit=limit, offset=offset)

View File

@ -1,696 +1,9 @@
import time
import shlex
from typing import Any, Dict, Optional
import logging
from fastapi import APIRouter
import httpx
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from clients.edge.errors import log_edge_failure, summarize_edge_exception
from clients.edge.http import HttpEdgeClient
from core.cache import cache
from core.database import get_session
from models.bot import BotInstance
from providers.target import ProviderTarget
from providers.selector import get_runtime_provider
from schemas.platform import (
ManagedNodeConnectivityResult,
ManagedNodeNativePreflightResult,
ManagedNodePayload,
PlatformSettingsPayload,
SystemSettingPayload,
)
from services.node_registry_service import ManagedNode
from services.platform_service import (
build_node_resource_overview,
build_platform_overview,
create_or_update_system_setting,
delete_system_setting,
get_platform_settings,
list_system_settings,
list_activity_events,
list_usage,
save_platform_settings,
)
from api.platform_admin_router import router as platform_admin_router
from api.platform_nodes_router import router as platform_nodes_router
router = APIRouter()
logger = logging.getLogger(__name__)
PLATFORM_OVERVIEW_CACHE_KEY = "platform:overview"
PLATFORM_OVERVIEW_CACHE_TTL_SECONDS = 15
PLATFORM_NODES_CACHE_KEY = "platform:nodes:list"
PLATFORM_NODES_CACHE_TTL_SECONDS = 20
router.include_router(platform_admin_router)
router.include_router(platform_nodes_router)
def _cached_platform_overview_payload() -> Optional[Dict[str, Any]]:
cached = cache.get_json(PLATFORM_OVERVIEW_CACHE_KEY)
return cached if isinstance(cached, dict) else None
def _store_platform_overview_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
cache.set_json(PLATFORM_OVERVIEW_CACHE_KEY, payload, ttl=PLATFORM_OVERVIEW_CACHE_TTL_SECONDS)
return payload
def _invalidate_platform_overview_cache() -> None:
cache.delete(PLATFORM_OVERVIEW_CACHE_KEY)
def _cached_platform_nodes_payload() -> Optional[Dict[str, Any]]:
cached = cache.get_json(PLATFORM_NODES_CACHE_KEY)
if not isinstance(cached, dict):
return None
items = cached.get("items")
if not isinstance(items, list):
return None
return {"items": items}
def _store_platform_nodes_payload(items: list[Dict[str, Any]]) -> Dict[str, Any]:
payload = {"items": items}
cache.set_json(PLATFORM_NODES_CACHE_KEY, payload, ttl=PLATFORM_NODES_CACHE_TTL_SECONDS)
return payload
def _invalidate_platform_nodes_cache() -> None:
cache.delete(PLATFORM_NODES_CACHE_KEY)
def _normalize_node_payload(payload: ManagedNodePayload) -> ManagedNodePayload:
normalized_node_id = str(payload.node_id or "").strip().lower()
if not normalized_node_id:
raise HTTPException(status_code=400, detail="node_id is required")
transport_kind = str(payload.transport_kind or "edge").strip().lower() or "edge"
if transport_kind != "edge":
raise HTTPException(status_code=400, detail="Only edge transport is supported")
runtime_kind = str(payload.runtime_kind or "docker").strip().lower() or "docker"
core_adapter = str(payload.core_adapter or "nanobot").strip().lower() or "nanobot"
native_sandbox_mode = _normalize_native_sandbox_mode(payload.native_sandbox_mode)
base_url = str(payload.base_url or "").strip()
if transport_kind == "edge" and not base_url:
raise HTTPException(status_code=400, detail="base_url is required for edge nodes")
return payload.model_copy(
update={
"node_id": normalized_node_id,
"display_name": str(payload.display_name or normalized_node_id).strip() or normalized_node_id,
"base_url": base_url,
"auth_token": str(payload.auth_token or "").strip(),
"transport_kind": transport_kind,
"runtime_kind": runtime_kind,
"core_adapter": core_adapter,
"workspace_root": str(payload.workspace_root or "").strip(),
"native_command": str(payload.native_command or "").strip(),
"native_workdir": str(payload.native_workdir or "").strip(),
"native_sandbox_mode": native_sandbox_mode,
}
)
def _normalize_native_sandbox_mode(raw_value: Any) -> str:
text = str(raw_value or "").strip().lower()
if text in {"workspace", "sandbox", "strict"}:
return "workspace"
if text in {"full_access", "full-access", "danger-full-access", "escape"}:
return "full_access"
return "inherit"
def _managed_node_from_payload(payload: ManagedNodePayload) -> ManagedNode:
normalized = _normalize_node_payload(payload)
return ManagedNode(
node_id=normalized.node_id,
display_name=normalized.display_name,
base_url=normalized.base_url,
enabled=bool(normalized.enabled),
auth_token=normalized.auth_token,
metadata={
"transport_kind": normalized.transport_kind,
"runtime_kind": normalized.runtime_kind,
"core_adapter": normalized.core_adapter,
"workspace_root": normalized.workspace_root,
"native_command": normalized.native_command,
"native_workdir": normalized.native_workdir,
"native_sandbox_mode": normalized.native_sandbox_mode,
},
)
def _node_status(node: ManagedNode, *, refresh_failed: bool = False) -> str:
if not bool(node.enabled):
return "disabled"
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
if transport_kind != "edge":
return "unknown"
if refresh_failed:
return "offline"
return "online" if node.last_seen_at else "unknown"
def _serialize_node(node: ManagedNode, *, refresh_failed: bool = False) -> Dict[str, Any]:
metadata = dict(node.metadata or {})
return {
"node_id": node.node_id,
"display_name": node.display_name,
"base_url": node.base_url,
"enabled": bool(node.enabled),
"transport_kind": str(metadata.get("transport_kind") or ""),
"runtime_kind": str(metadata.get("runtime_kind") or ""),
"core_adapter": str(metadata.get("core_adapter") or ""),
"workspace_root": str(metadata.get("workspace_root") or ""),
"native_command": str(metadata.get("native_command") or ""),
"native_workdir": str(metadata.get("native_workdir") or ""),
"native_sandbox_mode": str(metadata.get("native_sandbox_mode") or "inherit"),
"metadata": metadata,
"capabilities": dict(node.capabilities or {}),
"resources": dict(getattr(node, "resources", {}) or {}),
"last_seen_at": node.last_seen_at,
"status": _node_status(node, refresh_failed=refresh_failed),
}
def _test_edge_connectivity(resolve_edge_client, node: ManagedNode) -> ManagedNodeConnectivityResult:
started = time.perf_counter()
try:
client = resolve_edge_client(
ProviderTarget(
node_id=node.node_id,
transport_kind="edge",
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
)
)
node_self = _edge_node_self_with_native_preflight(client=client, node=node)
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
return ManagedNodeConnectivityResult(
ok=True,
status="online",
latency_ms=latency_ms,
detail="dashboard-edge reachable",
node_self=node_self,
)
except Exception as exc:
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
return ManagedNodeConnectivityResult(
ok=False,
status="offline",
latency_ms=latency_ms,
detail=summarize_edge_exception(exc),
node_self=None,
)
def _split_native_command(raw_command: Optional[str]) -> list[str]:
text = str(raw_command or "").strip()
if not text:
return []
try:
return [str(item or "").strip() for item in shlex.split(text) if str(item or "").strip()]
except Exception:
return [text]
def _runtime_native_supported(node_self: Dict[str, Any]) -> bool:
capabilities = dict(node_self.get("capabilities") or {})
runtime_caps = dict(capabilities.get("runtime") or {})
return bool(runtime_caps.get("native") is True)
def _test_edge_native_preflight(
resolve_edge_client,
node: ManagedNode,
*,
native_command: Optional[str] = None,
native_workdir: Optional[str] = None,
) -> ManagedNodeNativePreflightResult:
started = time.perf_counter()
command_hint = _split_native_command(native_command)
workdir_hint = str(native_workdir or "").strip()
try:
client = resolve_edge_client(
ProviderTarget(
node_id=node.node_id,
transport_kind="edge",
runtime_kind=str((node.metadata or {}).get("runtime_kind") or "docker"),
core_adapter=str((node.metadata or {}).get("core_adapter") or "nanobot"),
)
)
node_self = dict(client.heartbeat_node() or {})
preflight = dict(
client.preflight_native(
native_command=native_command,
native_workdir=native_workdir,
)
or {}
)
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
command = [str(item or "").strip() for item in list(preflight.get("command") or []) if str(item or "").strip()]
workdir = str(preflight.get("workdir") or "")
detail = str(preflight.get("detail") or "")
if not detail:
detail = "native launcher ready" if bool(preflight.get("ok")) else "native launcher not ready"
return ManagedNodeNativePreflightResult(
ok=bool(preflight.get("ok")),
status="online",
latency_ms=latency_ms,
detail=detail,
command=command,
workdir=workdir,
command_available=bool(preflight.get("command_available")),
workdir_exists=bool(preflight.get("workdir_exists")),
runtime_native_supported=_runtime_native_supported(node_self),
node_self=node_self,
)
except Exception as exc:
latency_ms = max(1, int((time.perf_counter() - started) * 1000))
return ManagedNodeNativePreflightResult(
ok=False,
status="offline",
latency_ms=latency_ms,
detail=summarize_edge_exception(exc),
command=command_hint,
workdir=workdir_hint,
command_available=False,
workdir_exists=False if workdir_hint else True,
runtime_native_supported=False,
node_self=None,
)
def _edge_node_self_with_native_preflight(*, client: HttpEdgeClient, node: ManagedNode) -> Dict[str, Any]:
node_self = dict(client.heartbeat_node() or {})
metadata = dict(node.metadata or {})
native_command = str(metadata.get("native_command") or "").strip() or None
native_workdir = str(metadata.get("native_workdir") or "").strip() or None
runtime_kind = str(metadata.get("runtime_kind") or "docker").strip().lower()
should_probe = bool(native_command or native_workdir or runtime_kind == "native")
if not should_probe:
return node_self
try:
preflight = dict(client.preflight_native(native_command=native_command, native_workdir=native_workdir) or {})
except Exception as exc:
log_edge_failure(
logger,
key=f"platform-node-native-preflight:{node.node_id}",
exc=exc,
message=f"Failed to run native preflight for node_id={node.node_id}",
)
return node_self
caps = dict(node_self.get("capabilities") or {})
process_caps = dict(caps.get("process") or {})
if preflight.get("command"):
process_caps["command"] = list(preflight.get("command") or [])
process_caps["available"] = bool(preflight.get("ok"))
process_caps["command_available"] = bool(preflight.get("command_available"))
process_caps["workdir_exists"] = bool(preflight.get("workdir_exists"))
process_caps["workdir"] = str(preflight.get("workdir") or "")
process_caps["detail"] = str(preflight.get("detail") or "")
caps["process"] = process_caps
node_self["capabilities"] = caps
node_self["native_preflight"] = preflight
return node_self
def _apply_platform_runtime_changes(request: Request) -> None:
_invalidate_platform_overview_cache()
_invalidate_platform_nodes_cache()
speech_service = getattr(request.app.state, "speech_service", None)
if speech_service is not None and hasattr(speech_service, "reset_runtime"):
speech_service.reset_runtime()
@router.get("/api/platform/overview")
def get_platform_overview(request: Request, session: Session = Depends(get_session)):
cached_payload = _cached_platform_overview_payload()
if cached_payload is not None:
return cached_payload
def _read_runtime(bot):
provider = get_runtime_provider(request.app.state, bot)
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
runtime.setdefault("docker_status", status)
return status, runtime
payload = build_platform_overview(session, read_runtime=_read_runtime)
return _store_platform_overview_payload(payload)
@router.get("/api/platform/nodes")
def list_platform_nodes(request: Request, session: Session = Depends(get_session)):
cached_payload = _cached_platform_nodes_payload()
if cached_payload is not None:
return cached_payload
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "list_nodes"):
return {"items": []}
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
refreshed_items = []
for node in node_registry.list_nodes():
metadata = dict(node.metadata or {})
refresh_failed = False
if (
callable(resolve_edge_client)
and str(metadata.get("transport_kind") or "").strip().lower() == "edge"
and bool(node.enabled)
):
try:
client = resolve_edge_client(
ProviderTarget(
node_id=node.node_id,
transport_kind="edge",
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
)
)
node_self = _edge_node_self_with_native_preflight(client=client, node=node)
node = node_registry.mark_node_seen(
session,
node_id=node.node_id,
display_name=str(node.display_name or node_self.get("display_name") or node.node_id),
capabilities=dict(node_self.get("capabilities") or {}),
resources=dict(node_self.get("resources") or {}),
)
except Exception as exc:
refresh_failed = True
log_edge_failure(
logger,
key=f"platform-node-refresh:{node.node_id}",
exc=exc,
message=f"Failed to refresh edge node metadata for node_id={node.node_id}",
)
refreshed_items.append((node, refresh_failed))
items = []
for node, refresh_failed in refreshed_items:
items.append(_serialize_node(node, refresh_failed=refresh_failed))
return _store_platform_nodes_payload(items)
@router.get("/api/platform/nodes/{node_id}")
def get_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
node = node_registry.get_node(normalized_node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
return _serialize_node(node)
@router.post("/api/platform/nodes")
def create_platform_node(payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
normalized = _normalize_node_payload(payload)
if node_registry.get_node(normalized.node_id) is not None:
raise HTTPException(status_code=409, detail=f"Node already exists: {normalized.node_id}")
node = node_registry.upsert_node(session, _managed_node_from_payload(normalized))
_invalidate_platform_overview_cache()
_invalidate_platform_nodes_cache()
return _serialize_node(node)
@router.put("/api/platform/nodes/{node_id}")
def update_platform_node(node_id: str, payload: ManagedNodePayload, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
existing = node_registry.get_node(normalized_node_id)
if existing is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
normalized = _normalize_node_payload(payload)
if normalized.node_id != normalized_node_id:
raise HTTPException(status_code=400, detail="node_id cannot be changed")
node = node_registry.upsert_node(
session,
ManagedNode(
node_id=normalized_node_id,
display_name=normalized.display_name,
base_url=normalized.base_url,
enabled=bool(normalized.enabled),
auth_token=normalized.auth_token or existing.auth_token,
metadata={
"transport_kind": normalized.transport_kind,
"runtime_kind": normalized.runtime_kind,
"core_adapter": normalized.core_adapter,
"workspace_root": normalized.workspace_root,
"native_command": normalized.native_command,
"native_workdir": normalized.native_workdir,
"native_sandbox_mode": normalized.native_sandbox_mode,
},
capabilities=dict(existing.capabilities or {}),
resources=dict(existing.resources or {}),
last_seen_at=existing.last_seen_at,
),
)
_invalidate_platform_overview_cache()
_invalidate_platform_nodes_cache()
return _serialize_node(node)
@router.delete("/api/platform/nodes/{node_id}")
def delete_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
if normalized_node_id == "local":
raise HTTPException(status_code=400, detail="Local node cannot be deleted")
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
if node_registry.get_node(normalized_node_id) is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
attached_bot_ids = session.exec(select(BotInstance.id).where(BotInstance.node_id == normalized_node_id)).all()
if attached_bot_ids:
raise HTTPException(
status_code=400,
detail=f"Node {normalized_node_id} still has bots assigned: {', '.join(str(item) for item in attached_bot_ids[:5])}",
)
node_registry.delete_node(session, normalized_node_id)
_invalidate_platform_overview_cache()
_invalidate_platform_nodes_cache()
return {"status": "deleted", "node_id": normalized_node_id}
@router.post("/api/platform/nodes/test")
def test_platform_node(payload: ManagedNodePayload, request: Request):
normalized = _normalize_node_payload(payload)
temp_node = _managed_node_from_payload(normalized)
result = _test_edge_connectivity(
lambda _target: HttpEdgeClient(
node=temp_node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
temp_node,
)
return result.model_dump()
@router.post("/api/platform/nodes/native/preflight")
def test_platform_node_native_preflight(payload: ManagedNodePayload, request: Request):
normalized = _normalize_node_payload(payload)
temp_node = _managed_node_from_payload(normalized)
result = _test_edge_native_preflight(
lambda _target: HttpEdgeClient(
node=temp_node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
temp_node,
native_command=str(normalized.native_command or "").strip() or None,
native_workdir=str(normalized.native_workdir or "").strip() or None,
)
return result.model_dump()
@router.post("/api/platform/nodes/{node_id}/test")
def test_saved_platform_node(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
node = node_registry.get_node(normalized_node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
if transport_kind != "edge":
_invalidate_platform_nodes_cache()
raise HTTPException(status_code=400, detail="Only edge transport is supported")
result = _test_edge_connectivity(
lambda target: HttpEdgeClient(
node=node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
node,
)
if result.ok:
node_registry.mark_node_seen(
session,
node_id=node.node_id,
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id) if result.node_self else node.display_name,
capabilities=dict(result.node_self.get("capabilities") or {}) if result.node_self else dict(node.capabilities or {}),
resources=dict(result.node_self.get("resources") or {}) if result.node_self else dict(getattr(node, "resources", {}) or {}),
)
_invalidate_platform_nodes_cache()
return result.model_dump()
@router.post("/api/platform/nodes/{node_id}/native/preflight")
def test_saved_platform_node_native_preflight(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is None or not hasattr(node_registry, "get_node"):
raise HTTPException(status_code=500, detail="node registry is unavailable")
node = node_registry.get_node(normalized_node_id)
if node is None:
raise HTTPException(status_code=404, detail=f"Managed node not found: {normalized_node_id}")
transport_kind = str((node.metadata or {}).get("transport_kind") or "edge").strip().lower()
if transport_kind != "edge":
_invalidate_platform_nodes_cache()
raise HTTPException(status_code=400, detail="Only edge transport is supported")
metadata = dict(node.metadata or {})
result = _test_edge_native_preflight(
lambda _target: HttpEdgeClient(
node=node,
http_client_factory=lambda: httpx.Client(timeout=10.0, trust_env=False),
async_http_client_factory=lambda: httpx.AsyncClient(timeout=10.0, trust_env=False),
),
node,
native_command=str(metadata.get("native_command") or "").strip() or None,
native_workdir=str(metadata.get("native_workdir") or "").strip() or None,
)
if result.status == "online" and result.node_self:
node_registry.mark_node_seen(
session,
node_id=node.node_id,
display_name=str(node.display_name or result.node_self.get("display_name") or node.node_id),
capabilities=dict(result.node_self.get("capabilities") or {}),
resources=dict(result.node_self.get("resources") or {}),
)
_invalidate_platform_nodes_cache()
return result.model_dump()
@router.get("/api/platform/nodes/{node_id}/resources")
def get_platform_node_resources(node_id: str, request: Request, session: Session = Depends(get_session)):
normalized_node_id = str(node_id or "").strip().lower()
node_registry = getattr(request.app.state, "node_registry_service", None)
if node_registry is not None and hasattr(node_registry, "get_node"):
node = node_registry.get_node(normalized_node_id)
if node is not None:
metadata = dict(getattr(node, "metadata", {}) or {})
if str(metadata.get("transport_kind") or "").strip().lower() == "edge":
resolve_edge_client = getattr(request.app.state, "resolve_edge_client", None)
if callable(resolve_edge_client):
from providers.target import ProviderTarget
base = build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=None)
client = resolve_edge_client(
ProviderTarget(
node_id=normalized_node_id,
transport_kind="edge",
runtime_kind=str(metadata.get("runtime_kind") or "docker"),
core_adapter=str(metadata.get("core_adapter") or "nanobot"),
)
)
try:
resource_report = dict(client.get_node_resources() or {})
except Exception as exc:
log_edge_failure(
logger,
key=f"platform-node-resources:{normalized_node_id}",
exc=exc,
message=f"Failed to load edge node resources for node_id={normalized_node_id}",
)
return base
base["resources"] = dict(resource_report.get("resources") or resource_report)
if resource_report:
base["node_report"] = resource_report
return base
def _read_runtime(bot):
provider = get_runtime_provider(request.app.state, bot)
status = str(provider.get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
runtime = dict(provider.get_resource_snapshot(bot_id=str(bot.id or "")) or {})
runtime.setdefault("docker_status", status)
return status, runtime
return build_node_resource_overview(session, node_id=normalized_node_id, read_runtime=_read_runtime)
@router.get("/api/platform/settings")
def get_platform_settings_api(session: Session = Depends(get_session)):
return get_platform_settings(session).model_dump()
@router.put("/api/platform/settings")
def update_platform_settings_api(payload: PlatformSettingsPayload, request: Request, session: Session = Depends(get_session)):
result = save_platform_settings(session, payload).model_dump()
_apply_platform_runtime_changes(request)
return result
@router.post("/api/platform/cache/clear")
def clear_platform_cache():
_invalidate_platform_overview_cache()
_invalidate_platform_nodes_cache()
return {"status": "cleared"}
@router.post("/api/platform/reload")
def reload_platform_runtime(request: Request):
_apply_platform_runtime_changes(request)
return {"status": "reloaded"}
@router.get("/api/platform/usage")
def get_platform_usage(
bot_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
session: Session = Depends(get_session),
):
return list_usage(session, bot_id=bot_id, limit=limit, offset=offset)
@router.get("/api/platform/events")
def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session: Session = Depends(get_session)):
return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)}
@router.get("/api/platform/system-settings")
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
return {"items": list_system_settings(session, search=search)}
@router.post("/api/platform/system-settings")
def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
try:
result = create_or_update_system_setting(session, payload)
_apply_platform_runtime_changes(request)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/api/platform/system-settings/{key}")
def update_system_setting(key: str, payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
try:
result = create_or_update_system_setting(session, payload.model_copy(update={"key": key}))
_apply_platform_runtime_changes(request)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/api/platform/system-settings/{key}")
def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
try:
delete_system_setting(session, key)
_apply_platform_runtime_changes(request)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"status": "deleted", "key": key}

View File

@ -0,0 +1,71 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session
from api.platform_shared import apply_platform_runtime_changes
from core.database import get_session
from schemas.platform import PlatformSettingsPayload, SystemSettingPayload
from services.platform_settings_service import (
create_or_update_system_setting,
delete_system_setting,
get_platform_settings,
list_system_settings,
save_platform_settings,
)
router = APIRouter()
@router.get("/api/platform/settings")
def get_platform_settings_api(session: Session = Depends(get_session)):
return get_platform_settings(session).model_dump()
@router.put("/api/platform/settings")
def update_platform_settings_api(
payload: PlatformSettingsPayload,
request: Request,
session: Session = Depends(get_session),
):
result = save_platform_settings(session, payload).model_dump()
apply_platform_runtime_changes(request)
return result
@router.get("/api/platform/system-settings")
def get_system_settings(search: str = "", session: Session = Depends(get_session)):
return {"items": list_system_settings(session, search=search)}
@router.post("/api/platform/system-settings")
def create_system_setting(payload: SystemSettingPayload, request: Request, session: Session = Depends(get_session)):
try:
result = create_or_update_system_setting(session, payload)
apply_platform_runtime_changes(request)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.put("/api/platform/system-settings/{key}")
def update_system_setting(
key: str,
payload: SystemSettingPayload,
request: Request,
session: Session = Depends(get_session),
):
try:
result = create_or_update_system_setting(session, payload.model_copy(update={"key": key}))
apply_platform_runtime_changes(request)
return result
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
@router.delete("/api/platform/system-settings/{key}")
def remove_system_setting(key: str, request: Request, session: Session = Depends(get_session)):
try:
delete_system_setting(session, key)
apply_platform_runtime_changes(request)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"status": "deleted", "key": key}

View File

@ -0,0 +1,54 @@
import logging
from typing import Any, Dict, Optional
from fastapi import Request
from core.cache import cache
logger = logging.getLogger(__name__)
PLATFORM_OVERVIEW_CACHE_KEY = "platform:overview"
PLATFORM_OVERVIEW_CACHE_TTL_SECONDS = 15
PLATFORM_NODES_CACHE_KEY = "platform:nodes:list"
PLATFORM_NODES_CACHE_TTL_SECONDS = 20
def cached_platform_overview_payload() -> Optional[Dict[str, Any]]:
cached = cache.get_json(PLATFORM_OVERVIEW_CACHE_KEY)
return cached if isinstance(cached, dict) else None
def store_platform_overview_payload(payload: Dict[str, Any]) -> Dict[str, Any]:
cache.set_json(PLATFORM_OVERVIEW_CACHE_KEY, payload, ttl=PLATFORM_OVERVIEW_CACHE_TTL_SECONDS)
return payload
def invalidate_platform_overview_cache() -> None:
cache.delete(PLATFORM_OVERVIEW_CACHE_KEY)
def cached_platform_nodes_payload() -> Optional[Dict[str, Any]]:
cached = cache.get_json(PLATFORM_NODES_CACHE_KEY)
if not isinstance(cached, dict):
return None
items = cached.get("items")
if not isinstance(items, list):
return None
return {"items": items}
def store_platform_nodes_payload(items: list[Dict[str, Any]]) -> Dict[str, Any]:
payload = {"items": items}
cache.set_json(PLATFORM_NODES_CACHE_KEY, payload, ttl=PLATFORM_NODES_CACHE_TTL_SECONDS)
return payload
def invalidate_platform_nodes_cache() -> None:
cache.delete(PLATFORM_NODES_CACHE_KEY)
def apply_platform_runtime_changes(request: Request) -> None:
invalidate_platform_overview_cache()
invalidate_platform_nodes_cache()
speech_service = getattr(request.app.state, "speech_service", None)
if speech_service is not None and hasattr(speech_service, "reset_runtime"):
speech_service.reset_runtime()

View File

@ -0,0 +1,230 @@
from fastapi import APIRouter, Depends, HTTPException, Request
from sqlmodel import Session, select
from core.database import get_session
from models.sys_auth import SysUser
from schemas.sys_auth import (
SysAuthBootstrapResponse,
SysAuthLoginRequest,
SysProfileUpdateRequest,
SysAuthStatusResponse,
SysRoleGrantBootstrapResponse,
SysRoleListResponse,
SysRoleSummaryResponse,
SysRoleUpsertRequest,
SysUserCreateRequest,
SysUserListResponse,
SysUserSummaryResponse,
SysUserUpdateRequest,
)
from services.sys_auth_service import (
DEFAULT_ADMIN_USERNAME,
authenticate_user,
build_user_bootstrap,
create_sys_role,
create_sys_user,
delete_sys_role,
delete_sys_user,
issue_user_token,
list_role_grant_bootstrap,
list_sys_roles,
list_sys_users,
resolve_user_by_token,
revoke_user_token,
update_sys_role,
update_sys_user,
update_current_sys_user_profile,
)
router = APIRouter()
def _extract_auth_token(request: Request) -> str:
authorization = str(request.headers.get("authorization") or "").strip()
if authorization.lower().startswith("bearer "):
return authorization[7:].strip()
return str(request.headers.get("x-auth-token") or request.query_params.get("auth_token") or "").strip()
def _require_current_user(request: Request, session: Session) -> SysUser:
state_user_id = getattr(request.state, "sys_user_id", None)
if state_user_id:
user = session.get(SysUser, state_user_id)
if user is not None and bool(user.is_active):
return user
token = _extract_auth_token(request)
user = resolve_user_by_token(session, token)
if user is None:
raise HTTPException(status_code=401, detail="Authentication required")
return user
@router.get("/api/sys/auth/status", response_model=SysAuthStatusResponse)
def get_sys_auth_status(session: Session = Depends(get_session)):
user_count = len(session.exec(select(SysUser)).all())
return SysAuthStatusResponse(
enabled=True,
user_count=user_count,
default_username=DEFAULT_ADMIN_USERNAME,
)
@router.post("/api/sys/auth/login", response_model=SysAuthBootstrapResponse)
def login_sys_user(payload: SysAuthLoginRequest, session: Session = Depends(get_session)):
username = str(payload.username or "").strip().lower()
password = str(payload.password or "")
user = authenticate_user(session, username, password)
if user is None:
raise HTTPException(status_code=401, detail="Invalid username or password")
try:
token, expires_at = issue_user_token(session, user)
except RuntimeError as exc:
raise HTTPException(status_code=503, detail=str(exc)) from exc
return SysAuthBootstrapResponse.model_validate(build_user_bootstrap(session, user, token=token, expires_at=expires_at))
@router.post("/api/sys/auth/logout")
def logout_sys_user(request: Request, session: Session = Depends(get_session)):
token = _extract_auth_token(request)
user = resolve_user_by_token(session, token)
if user is not None:
revoke_user_token(token)
return {"success": True}
@router.get("/api/sys/auth/me", response_model=SysAuthBootstrapResponse)
def get_current_sys_user(request: Request, session: Session = Depends(get_session)):
user = _require_current_user(request, session)
return SysAuthBootstrapResponse.model_validate(build_user_bootstrap(session, user))
@router.put("/api/sys/auth/me", response_model=SysAuthBootstrapResponse)
def update_current_sys_user(
payload: SysProfileUpdateRequest,
request: Request,
session: Session = Depends(get_session),
):
current_user = _require_current_user(request, session)
try:
user = update_current_sys_user_profile(
session,
user_id=int(current_user.id or 0),
display_name=payload.display_name,
password=payload.password,
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return SysAuthBootstrapResponse.model_validate(build_user_bootstrap(session, user))
@router.get("/api/sys/users", response_model=SysUserListResponse)
def list_sys_users_api(request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
return SysUserListResponse(items=[SysUserSummaryResponse.model_validate(item) for item in list_sys_users(session)])
@router.post("/api/sys/users", response_model=SysUserSummaryResponse)
def create_sys_user_api(payload: SysUserCreateRequest, request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
try:
item = create_sys_user(
session,
username=payload.username,
display_name=payload.display_name,
password=payload.password,
role_id=int(payload.role_id),
is_active=bool(payload.is_active),
bot_ids=list(payload.bot_ids or []),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return SysUserSummaryResponse.model_validate(item)
@router.put("/api/sys/users/{user_id}", response_model=SysUserSummaryResponse)
def update_sys_user_api(user_id: int, payload: SysUserUpdateRequest, request: Request, session: Session = Depends(get_session)):
current_user = _require_current_user(request, session)
try:
item = update_sys_user(
session,
user_id=int(user_id),
display_name=payload.display_name,
password=payload.password,
role_id=int(payload.role_id),
is_active=bool(payload.is_active),
bot_ids=list(payload.bot_ids or []),
acting_user_id=int(current_user.id or 0),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return SysUserSummaryResponse.model_validate(item)
@router.delete("/api/sys/users/{user_id}")
def delete_sys_user_api(user_id: int, request: Request, session: Session = Depends(get_session)):
current_user = _require_current_user(request, session)
try:
delete_sys_user(session, user_id=int(user_id), acting_user_id=int(current_user.id or 0))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True}
@router.get("/api/sys/roles", response_model=SysRoleListResponse)
def list_sys_roles_api(request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
return SysRoleListResponse(items=[SysRoleSummaryResponse.model_validate(item) for item in list_sys_roles(session)])
@router.get("/api/sys/roles/grants/bootstrap", response_model=SysRoleGrantBootstrapResponse)
def list_sys_role_grants_bootstrap_api(request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
return SysRoleGrantBootstrapResponse.model_validate(list_role_grant_bootstrap(session))
@router.post("/api/sys/roles", response_model=SysRoleSummaryResponse)
def create_sys_role_api(payload: SysRoleUpsertRequest, request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
try:
item = create_sys_role(
session,
role_key=payload.role_key,
name=payload.name,
description=payload.description,
is_active=bool(payload.is_active),
sort_order=int(payload.sort_order),
menu_keys=list(payload.menu_keys or []),
permission_keys=list(payload.permission_keys or []),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return SysRoleSummaryResponse.model_validate(item)
@router.put("/api/sys/roles/{role_id}", response_model=SysRoleSummaryResponse)
def update_sys_role_api(role_id: int, payload: SysRoleUpsertRequest, request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
try:
item = update_sys_role(
session,
role_id=int(role_id),
name=payload.name,
description=payload.description,
is_active=bool(payload.is_active),
sort_order=int(payload.sort_order),
menu_keys=list(payload.menu_keys or []),
permission_keys=list(payload.permission_keys or []),
)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return SysRoleSummaryResponse.model_validate(item)
@router.delete("/api/sys/roles/{role_id}")
def delete_sys_role_api(role_id: int, request: Request, session: Session = Depends(get_session)):
_require_current_user(request, session)
try:
delete_sys_role(session, role_id=int(role_id))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
return {"success": True}

View File

@ -0,0 +1,29 @@
from fastapi import APIRouter
from schemas.dashboard import SystemTemplatesUpdateRequest
def build_system_runtime_router(*, system_service) -> APIRouter:
router = APIRouter()
@router.get("/api/system/defaults")
def get_system_defaults():
return system_service.get_system_defaults()
@router.get("/api/system/templates")
def get_system_templates():
return system_service.get_system_templates()
@router.put("/api/system/templates")
def update_system_templates(payload: SystemTemplatesUpdateRequest):
return system_service.update_system_templates(payload=payload)
@router.get("/api/health")
def get_health():
return system_service.get_health()
@router.get("/api/health/cache")
def get_cache_health():
return system_service.get_cache_health()
return router

View File

@ -0,0 +1,85 @@
import logging
import os
import re
from typing import Any
from fastapi import FastAPI, Request
from fastapi.middleware.cors import CORSMiddleware
from api.platform_router import router as platform_router
from api.sys_router import router as sys_router
from api.system_runtime_router import build_system_runtime_router
from api.topic_router import router as topic_router
from bootstrap.app_runtime import assemble_app_runtime
from core.config_manager import BotConfigManager
from core.docker_manager import BotDockerManager
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
from core.speech_service import WhisperSpeechService
app = FastAPI(title="Dashboard Nanobot API")
logger = logging.getLogger("dashboard.backend")
LAST_ACTION_MAX_LENGTH = 16000
def _normalize_last_action_text(value: Any) -> str:
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not text:
return ""
text = re.sub(r"\n{4,}", "\n\n\n", text)
return text[:LAST_ACTION_MAX_LENGTH]
def _apply_log_noise_guard() -> None:
for name in (
"httpx",
"httpcore",
"uvicorn.access",
"watchfiles.main",
"watchfiles.watcher",
):
logging.getLogger(name).setLevel(logging.WARNING)
_apply_log_noise_guard()
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
app.include_router(topic_router)
app.include_router(platform_router)
app.include_router(sys_router)
os.makedirs(BOTS_WORKSPACE_ROOT, exist_ok=True)
os.makedirs(DATA_ROOT, exist_ok=True)
docker_manager = BotDockerManager(host_data_root=BOTS_WORKSPACE_ROOT)
config_manager = BotConfigManager(host_data_root=BOTS_WORKSPACE_ROOT)
speech_service = WhisperSpeechService()
app.state.docker_manager = docker_manager
app.state.speech_service = speech_service
BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$")
runtime_assembly = assemble_app_runtime(
app=app,
logger=logger,
bots_workspace_root=BOTS_WORKSPACE_ROOT,
data_root=DATA_ROOT,
docker_manager=docker_manager,
config_manager=config_manager,
speech_service=speech_service,
bot_id_pattern=BOT_ID_PATTERN,
)
app.include_router(build_system_runtime_router(system_service=runtime_assembly.system_service))
@app.middleware("http")
async def bot_access_password_guard(request: Request, call_next):
return await runtime_assembly.dashboard_auth_service.guard(request, call_next)
@app.on_event("startup")
async def on_startup():
await runtime_assembly.app_lifecycle_service.on_startup()

View File

@ -0,0 +1,482 @@
from dataclasses import dataclass
from typing import Any, Dict
from clients.edge.errors import is_expected_edge_offline_error, log_edge_failure, summarize_edge_exception
from core.cache import cache
from core.database import engine, init_database
from core.settings import (
AGENT_MD_TEMPLATES_FILE,
DATABASE_ECHO,
DATABASE_ENGINE,
DATABASE_URL_DISPLAY,
DEFAULT_AGENTS_MD,
DEFAULT_BOT_SYSTEM_TIMEZONE,
DEFAULT_IDENTITY_MD,
DEFAULT_SOUL_MD,
DEFAULT_TOOLS_MD,
DEFAULT_USER_MD,
PROJECT_ROOT,
REDIS_ENABLED,
REDIS_PREFIX,
REDIS_URL,
TOPIC_PRESET_TEMPLATES,
TOPIC_PRESETS_TEMPLATES_FILE,
load_agent_md_templates,
load_topic_presets_template,
)
from providers.provision.edge import EdgeProvisionProvider
from providers.provision.local import LocalProvisionProvider
from providers.registry import ProviderRegistry
from providers.runtime.edge import EdgeRuntimeProvider
from providers.runtime.local import LocalRuntimeProvider
from providers.selector import get_provision_provider, get_runtime_provider
from providers.target import ProviderTarget, normalize_provider_target, provider_target_from_config, provider_target_to_dict
from providers.workspace.edge import EdgeWorkspaceProvider
from providers.workspace.local import LocalWorkspaceProvider
from services.app_lifecycle_service import AppLifecycleService
from services.bot_channel_service import BotChannelService
from services.bot_command_service import BotCommandService
from services.bot_config_state_service import BotConfigStateService
from services.bot_infra_service import BotInfraService
from services.bot_lifecycle_service import BotLifecycleService
from services.bot_message_service import BotMessageService
from services.bot_query_service import BotQueryService
from services.bot_runtime_snapshot_service import BotRuntimeSnapshotService
from services.dashboard_auth_service import DashboardAuthService
from services.image_service import ImageService
from services.node_registry_service import NodeRegistryService
from services.platform_activity_service import (
prune_expired_activity_events,
record_activity_event,
)
from services.platform_settings_service import (
get_chat_pull_page_size,
get_platform_settings_snapshot,
get_speech_runtime_settings,
)
from services.platform_usage_service import (
bind_usage_message,
create_usage_request,
fail_latest_usage,
finalize_usage_from_packet,
)
from services.provider_test_service import ProviderTestService
from services.runtime_event_service import RuntimeEventService
from services.runtime_service import RuntimeService
from services.skill_service import SkillService
from services.system_service import SystemService
from services.topic_runtime import publish_runtime_topic_packet
from services.workspace_service import WorkspaceService
from bootstrap.app_runtime_support import (
attach_runtime_services,
build_image_runtime_service,
build_speech_transcription_runtime_service,
build_system_runtime_service,
include_dashboard_api,
reconcile_image_registry,
register_provider_runtime,
)
@dataclass
class AppRuntimeAssembly:
dashboard_auth_service: DashboardAuthService
system_service: SystemService
app_lifecycle_service: AppLifecycleService
def assemble_app_runtime(
*,
app: Any,
logger: Any,
bots_workspace_root: str,
data_root: str,
docker_manager: Any,
config_manager: Any,
speech_service: Any,
bot_id_pattern: Any,
) -> AppRuntimeAssembly:
node_registry_service = NodeRegistryService()
skill_service = SkillService()
dashboard_auth_service = DashboardAuthService(engine=engine)
provider_registry = ProviderRegistry()
bot_infra_service = BotInfraService(
app=app,
engine=engine,
config_manager=config_manager,
node_registry_service=node_registry_service,
logger=logger,
bots_workspace_root=bots_workspace_root,
default_soul_md=DEFAULT_SOUL_MD,
default_agents_md=DEFAULT_AGENTS_MD,
default_user_md=DEFAULT_USER_MD,
default_tools_md=DEFAULT_TOOLS_MD,
default_identity_md=DEFAULT_IDENTITY_MD,
default_bot_system_timezone=DEFAULT_BOT_SYSTEM_TIMEZONE,
normalize_provider_target=normalize_provider_target,
provider_target_from_config=provider_target_from_config,
provider_target_to_dict=provider_target_to_dict,
resolve_provider_bundle_key=lambda target: provider_registry.resolve_bundle_key(target),
get_provision_provider=get_provision_provider,
read_env_store=lambda bot_id: bot_config_state_service.read_env_store(bot_id),
read_bot_runtime_snapshot=lambda bot: _read_bot_runtime_snapshot(bot),
normalize_media_list=lambda raw, bot_id: _normalize_media_list(raw, bot_id),
)
node_registry_service.register_node(bot_infra_service.local_managed_node())
app.state.node_registry_service = node_registry_service
_read_bot_config = bot_infra_service.read_bot_config
_write_bot_config = bot_infra_service.write_bot_config
_default_provider_target = bot_infra_service.default_provider_target
_read_bot_provider_target = bot_infra_service.read_bot_provider_target
_resolve_bot_provider_target_for_instance = bot_infra_service.resolve_bot_provider_target_for_instance
_clear_provider_target_override = bot_infra_service.clear_provider_target_override
_apply_provider_target_to_bot = bot_infra_service.apply_provider_target_to_bot
_local_managed_node = bot_infra_service.local_managed_node
_provider_target_from_node = bot_infra_service.provider_target_from_node
_node_display_name = bot_infra_service.node_display_name
_node_metadata = bot_infra_service.node_metadata
_serialize_provider_target_summary = bot_infra_service.serialize_provider_target_summary
_resolve_edge_client = bot_infra_service.resolve_edge_client
_resolve_edge_state_context = bot_infra_service.resolve_edge_state_context
_read_edge_state_data = bot_infra_service.read_edge_state_data
_write_edge_state_data = bot_infra_service.write_edge_state_data
_read_bot_resources = bot_infra_service.read_bot_resources
_migrate_bot_resources_store = bot_infra_service.migrate_bot_resources_store
_normalize_channel_extra = bot_infra_service.normalize_channel_extra
_read_global_delivery_flags = bot_infra_service.read_global_delivery_flags
_channel_api_to_cfg = bot_infra_service.channel_api_to_cfg
_get_bot_channels_from_config = bot_infra_service.get_bot_channels_from_config
_normalize_initial_channels = bot_infra_service.normalize_initial_channels
_parse_message_media = bot_infra_service.parse_message_media
_normalize_env_params = bot_infra_service.normalize_env_params
_get_default_system_timezone = bot_infra_service.get_default_system_timezone
_normalize_system_timezone = bot_infra_service.normalize_system_timezone
_resolve_bot_env_params = bot_infra_service.resolve_bot_env_params
_safe_float = bot_infra_service.safe_float
_safe_int = bot_infra_service.safe_int
_normalize_resource_limits = bot_infra_service.normalize_resource_limits
_sync_workspace_channels = bot_infra_service.sync_workspace_channels
_set_bot_provider_target = bot_infra_service.set_bot_provider_target
_sync_bot_workspace_via_provider = bot_infra_service.sync_bot_workspace_via_provider
_workspace_root = bot_infra_service.workspace_root
_cron_store_path = bot_infra_service.cron_store_path
_env_store_path = bot_infra_service.env_store_path
_clear_bot_sessions = bot_infra_service.clear_bot_sessions
_clear_bot_dashboard_direct_session = bot_infra_service.clear_bot_dashboard_direct_session
_ensure_provider_target_supported = bot_infra_service.ensure_provider_target_supported
_resolve_workspace_path = bot_infra_service.resolve_workspace_path
_calc_dir_size_bytes = bot_infra_service.calc_dir_size_bytes
_is_video_attachment_path = bot_infra_service.is_video_attachment_path
_is_visual_attachment_path = bot_infra_service.is_visual_attachment_path
bot_config_state_service = BotConfigStateService(
read_edge_state_data=_read_edge_state_data,
write_edge_state_data=_write_edge_state_data,
read_bot_config=_read_bot_config,
write_bot_config=_write_bot_config,
invalidate_bot_detail_cache=lambda *args, **kwargs: _invalidate_bot_detail_cache(*args, **kwargs),
env_store_path=_env_store_path,
cron_store_path=_cron_store_path,
normalize_env_params=_normalize_env_params,
)
def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None:
bot_config_state_service.write_env_store(bot_id, env_params)
local_provision_provider = LocalProvisionProvider(sync_workspace_func=_sync_workspace_channels)
local_runtime_provider = LocalRuntimeProvider(
docker_manager=docker_manager,
on_state_change=lambda *args, **kwargs: docker_callback(*args, **kwargs),
provision_provider=local_provision_provider,
read_runtime_snapshot=lambda *args, **kwargs: _read_bot_runtime_snapshot(*args, **kwargs),
resolve_env_params=_resolve_bot_env_params,
write_env_store=_write_env_store,
invalidate_bot_cache=lambda *args, **kwargs: _invalidate_bot_detail_cache(*args, **kwargs),
record_agent_loop_ready_warning=lambda *args, **kwargs: _record_agent_loop_ready_warning(*args, **kwargs),
safe_float=_safe_float,
safe_int=_safe_int,
)
local_workspace_provider = LocalWorkspaceProvider()
edge_provision_provider = EdgeProvisionProvider(
read_provider_target=_read_bot_provider_target,
resolve_edge_client=_resolve_edge_client,
read_runtime_snapshot=lambda *args, **kwargs: _read_bot_runtime_snapshot(*args, **kwargs),
read_bot_channels=_get_bot_channels_from_config,
read_node_metadata=_node_metadata,
)
edge_runtime_provider = EdgeRuntimeProvider(
read_provider_target=_read_bot_provider_target,
resolve_edge_client=_resolve_edge_client,
read_runtime_snapshot=lambda *args, **kwargs: _read_bot_runtime_snapshot(*args, **kwargs),
resolve_env_params=_resolve_bot_env_params,
read_bot_channels=_get_bot_channels_from_config,
read_node_metadata=_node_metadata,
)
edge_workspace_provider = EdgeWorkspaceProvider(
read_provider_target=_read_bot_provider_target,
resolve_edge_client=_resolve_edge_client,
read_node_metadata=_node_metadata,
)
local_provider_target = ProviderTarget(
node_id="local",
transport_kind="edge",
runtime_kind="docker",
core_adapter="nanobot",
)
register_provider_runtime(
app=app,
provider_registry=provider_registry,
local_provider_target=local_provider_target,
local_provision_provider=local_provision_provider,
local_runtime_provider=local_runtime_provider,
local_workspace_provider=local_workspace_provider,
edge_provision_provider=edge_provision_provider,
edge_runtime_provider=edge_runtime_provider,
edge_workspace_provider=edge_workspace_provider,
resolve_bot_provider_target_for_instance=_resolve_bot_provider_target_for_instance,
resolve_edge_client=_resolve_edge_client,
)
bot_runtime_snapshot_service = BotRuntimeSnapshotService(
engine=engine,
logger=logger,
docker_manager=docker_manager,
default_soul_md=DEFAULT_SOUL_MD,
default_agents_md=DEFAULT_AGENTS_MD,
default_user_md=DEFAULT_USER_MD,
default_tools_md=DEFAULT_TOOLS_MD,
default_identity_md=DEFAULT_IDENTITY_MD,
workspace_root=_workspace_root,
resolve_edge_state_context=_resolve_edge_state_context,
read_bot_config=_read_bot_config,
resolve_bot_env_params=_resolve_bot_env_params,
resolve_bot_provider_target_for_instance=_resolve_bot_provider_target_for_instance,
read_global_delivery_flags=_read_global_delivery_flags,
safe_float=_safe_float,
safe_int=_safe_int,
get_default_system_timezone=_get_default_system_timezone,
read_bot_resources=_read_bot_resources,
node_display_name=_node_display_name,
get_runtime_provider=get_runtime_provider,
invalidate_bot_detail_cache=lambda *args, **kwargs: _invalidate_bot_detail_cache(*args, **kwargs),
record_activity_event=record_activity_event,
)
_read_bot_runtime_snapshot = bot_runtime_snapshot_service.read_bot_runtime_snapshot
_serialize_bot = bot_runtime_snapshot_service.serialize_bot
_serialize_bot_list_item = bot_runtime_snapshot_service.serialize_bot_list_item
_refresh_bot_runtime_status = bot_runtime_snapshot_service.refresh_bot_runtime_status
_record_agent_loop_ready_warning = bot_runtime_snapshot_service.record_agent_loop_ready_warning
runtime_event_service = RuntimeEventService(
app=app,
engine=engine,
cache=cache,
logger=logger,
publish_runtime_topic_packet=publish_runtime_topic_packet,
bind_usage_message=bind_usage_message,
finalize_usage_from_packet=finalize_usage_from_packet,
workspace_root=_workspace_root,
parse_message_media=_parse_message_media,
)
_normalize_media_list = runtime_event_service.normalize_media_list
_persist_runtime_packet = runtime_event_service.persist_runtime_packet
_broadcast_runtime_packet = runtime_event_service.broadcast_runtime_packet
docker_callback = runtime_event_service.docker_callback
_cache_key_bots_list = runtime_event_service.cache_key_bots_list
_cache_key_bot_detail = runtime_event_service.cache_key_bot_detail
_cache_key_bot_messages = runtime_event_service.cache_key_bot_messages
_cache_key_bot_messages_page = runtime_event_service.cache_key_bot_messages_page
_serialize_bot_message_row = runtime_event_service.serialize_bot_message_row
_resolve_local_day_range = runtime_event_service.resolve_local_day_range
_cache_key_images = runtime_event_service.cache_key_images
_invalidate_bot_detail_cache = runtime_event_service.invalidate_bot_detail_cache
_invalidate_bot_messages_cache = runtime_event_service.invalidate_bot_messages_cache
_invalidate_images_cache = runtime_event_service.invalidate_images_cache
bot_command_service = BotCommandService(
read_runtime_snapshot=_read_bot_runtime_snapshot,
normalize_media_list=_normalize_media_list,
resolve_workspace_path=_resolve_workspace_path,
is_visual_attachment_path=_is_visual_attachment_path,
is_video_attachment_path=_is_video_attachment_path,
create_usage_request=create_usage_request,
record_activity_event=record_activity_event,
fail_latest_usage=fail_latest_usage,
persist_runtime_packet=_persist_runtime_packet,
get_main_loop=lambda app_state: getattr(app_state, "main_loop", None),
broadcast_packet=_broadcast_runtime_packet,
)
workspace_service = WorkspaceService()
runtime_service = RuntimeService(
command_service=bot_command_service,
resolve_runtime_provider=get_runtime_provider,
clear_bot_sessions=_clear_bot_sessions,
clear_dashboard_direct_session_file=_clear_bot_dashboard_direct_session,
invalidate_bot_detail_cache=_invalidate_bot_detail_cache,
invalidate_bot_messages_cache=_invalidate_bot_messages_cache,
record_activity_event=record_activity_event,
)
app_lifecycle_service = AppLifecycleService(
app=app,
engine=engine,
cache=cache,
logger=logger,
project_root=PROJECT_ROOT,
database_engine=DATABASE_ENGINE,
database_echo=DATABASE_ECHO,
database_url_display=DATABASE_URL_DISPLAY,
redis_enabled=REDIS_ENABLED,
init_database=init_database,
node_registry_service=node_registry_service,
local_managed_node=_local_managed_node,
prune_expired_activity_events=prune_expired_activity_events,
migrate_bot_resources_store=_migrate_bot_resources_store,
resolve_bot_provider_target_for_instance=_resolve_bot_provider_target_for_instance,
default_provider_target=_default_provider_target,
set_bot_provider_target=_set_bot_provider_target,
apply_provider_target_to_bot=_apply_provider_target_to_bot,
normalize_provider_target=normalize_provider_target,
runtime_service=runtime_service,
runtime_event_service=runtime_event_service,
clear_provider_target_overrides=bot_infra_service.clear_provider_target_overrides,
)
bot_query_service = BotQueryService(
cache=cache,
cache_key_bots_list=_cache_key_bots_list,
cache_key_bot_detail=_cache_key_bot_detail,
refresh_bot_runtime_status=_refresh_bot_runtime_status,
serialize_bot=_serialize_bot,
serialize_bot_list_item=_serialize_bot_list_item,
read_bot_resources=_read_bot_resources,
resolve_bot_provider_target=_resolve_bot_provider_target_for_instance,
get_runtime_provider=get_runtime_provider,
workspace_root=_workspace_root,
calc_dir_size_bytes=_calc_dir_size_bytes,
logger=logger,
)
bot_channel_service = BotChannelService(
read_bot_config=_read_bot_config,
write_bot_config=_write_bot_config,
sync_bot_workspace_via_provider=_sync_bot_workspace_via_provider,
invalidate_bot_detail_cache=_invalidate_bot_detail_cache,
get_bot_channels_from_config=_get_bot_channels_from_config,
normalize_channel_extra=_normalize_channel_extra,
channel_api_to_cfg=_channel_api_to_cfg,
read_global_delivery_flags=_read_global_delivery_flags,
)
bot_message_service = BotMessageService(
cache=cache,
cache_key_bot_messages=_cache_key_bot_messages,
cache_key_bot_messages_page=_cache_key_bot_messages_page,
serialize_bot_message_row=_serialize_bot_message_row,
resolve_local_day_range=_resolve_local_day_range,
invalidate_bot_messages_cache=_invalidate_bot_messages_cache,
get_chat_pull_page_size=get_chat_pull_page_size,
)
speech_transcription_service = build_speech_transcription_runtime_service(
data_root=data_root,
speech_service=speech_service,
get_speech_runtime_settings=get_speech_runtime_settings,
logger=logger,
)
image_service = build_image_runtime_service(
cache=cache,
cache_key_images=_cache_key_images,
invalidate_images_cache=_invalidate_images_cache,
docker_manager=docker_manager,
reconcile_image_registry_fn=lambda session: reconcile_image_registry(session, docker_manager=docker_manager),
)
provider_test_service = ProviderTestService()
system_service = build_system_runtime_service(
engine=engine,
cache=cache,
database_engine=DATABASE_ENGINE,
redis_enabled=REDIS_ENABLED,
redis_url=REDIS_URL,
redis_prefix=REDIS_PREFIX,
agent_md_templates_file=str(AGENT_MD_TEMPLATES_FILE),
topic_presets_templates_file=str(TOPIC_PRESETS_TEMPLATES_FILE),
default_soul_md=DEFAULT_SOUL_MD,
default_agents_md=DEFAULT_AGENTS_MD,
default_user_md=DEFAULT_USER_MD,
default_tools_md=DEFAULT_TOOLS_MD,
default_identity_md=DEFAULT_IDENTITY_MD,
topic_preset_templates=TOPIC_PRESET_TEMPLATES,
get_default_system_timezone=_get_default_system_timezone,
load_agent_md_templates=load_agent_md_templates,
load_topic_presets_template=load_topic_presets_template,
get_platform_settings_snapshot=get_platform_settings_snapshot,
get_speech_runtime_settings=get_speech_runtime_settings,
)
bot_lifecycle_service = BotLifecycleService(
bot_id_pattern=bot_id_pattern,
runtime_service=runtime_service,
refresh_bot_runtime_status=_refresh_bot_runtime_status,
resolve_bot_provider_target=_resolve_bot_provider_target_for_instance,
provider_target_from_node=_provider_target_from_node,
default_provider_target=_default_provider_target,
ensure_provider_target_supported=_ensure_provider_target_supported,
require_ready_image=image_service.require_ready_image,
sync_bot_workspace_via_provider=_sync_bot_workspace_via_provider,
apply_provider_target_to_bot=_apply_provider_target_to_bot,
serialize_provider_target_summary=_serialize_provider_target_summary,
serialize_bot=_serialize_bot,
node_display_name=_node_display_name,
invalidate_bot_detail_cache=_invalidate_bot_detail_cache,
record_activity_event=record_activity_event,
normalize_env_params=_normalize_env_params,
normalize_system_timezone=_normalize_system_timezone,
normalize_resource_limits=_normalize_resource_limits,
write_env_store=_write_env_store,
resolve_bot_env_params=_resolve_bot_env_params,
clear_provider_target_override=_clear_provider_target_override,
normalize_initial_channels=_normalize_initial_channels,
is_expected_edge_offline_error=is_expected_edge_offline_error,
summarize_edge_exception=summarize_edge_exception,
resolve_edge_client=_resolve_edge_client,
node_metadata=_node_metadata,
log_edge_failure=log_edge_failure,
invalidate_bot_messages_cache=_invalidate_bot_messages_cache,
logger=logger,
)
attach_runtime_services(
app=app,
bot_command_service=bot_command_service,
bot_lifecycle_service=bot_lifecycle_service,
app_lifecycle_service=app_lifecycle_service,
bot_query_service=bot_query_service,
bot_channel_service=bot_channel_service,
bot_message_service=bot_message_service,
bot_runtime_snapshot_service=bot_runtime_snapshot_service,
image_service=image_service,
provider_test_service=provider_test_service,
runtime_event_service=runtime_event_service,
speech_transcription_service=speech_transcription_service,
system_service=system_service,
workspace_service=workspace_service,
runtime_service=runtime_service,
)
include_dashboard_api(
app=app,
image_service=image_service,
provider_test_service=provider_test_service,
bot_lifecycle_service=bot_lifecycle_service,
bot_query_service=bot_query_service,
bot_channel_service=bot_channel_service,
skill_service=skill_service,
bot_config_state_service=bot_config_state_service,
runtime_service=runtime_service,
bot_message_service=bot_message_service,
workspace_service=workspace_service,
speech_transcription_service=speech_transcription_service,
app_lifecycle_service=app_lifecycle_service,
resolve_edge_state_context=_resolve_edge_state_context,
logger=logger,
)
return AppRuntimeAssembly(
dashboard_auth_service=dashboard_auth_service,
system_service=system_service,
app_lifecycle_service=app_lifecycle_service,
)

View File

@ -0,0 +1,231 @@
from typing import Any
from sqlmodel import Session, select
from api.dashboard_router import build_dashboard_router
from models.bot import NanobotImage
from services.image_service import ImageService
from services.speech_transcription_service import SpeechTranscriptionService
from services.system_service import SystemService
def reconcile_image_registry(session: Session, *, docker_manager: Any) -> None:
db_images = session.exec(select(NanobotImage)).all()
for image in db_images:
if docker_manager.has_image(image.tag):
try:
docker_image = docker_manager.client.images.get(image.tag) if docker_manager.client else None
image.image_id = docker_image.id if docker_image else image.image_id
except Exception:
pass
image.status = "READY"
else:
image.status = "UNKNOWN"
session.add(image)
session.commit()
def register_provider_runtime(
*,
app: Any,
provider_registry: Any,
local_provider_target: Any,
local_provision_provider: Any,
local_runtime_provider: Any,
local_workspace_provider: Any,
edge_provision_provider: Any,
edge_runtime_provider: Any,
edge_workspace_provider: Any,
resolve_bot_provider_target_for_instance: Any,
resolve_edge_client: Any,
) -> None:
provider_registry.register_bundle(
key=local_provider_target.key,
runtime_provider=local_runtime_provider,
workspace_provider=local_workspace_provider,
provision_provider=local_provision_provider,
)
provider_registry.register_bundle(
key=type(local_provider_target)(
node_id="local",
transport_kind="edge",
runtime_kind="docker",
core_adapter="nanobot",
).key,
runtime_provider=edge_runtime_provider,
workspace_provider=edge_workspace_provider,
provision_provider=edge_provision_provider,
)
provider_registry.register_bundle(
key=type(local_provider_target)(
node_id="local",
transport_kind="edge",
runtime_kind="native",
core_adapter="nanobot",
).key,
runtime_provider=edge_runtime_provider,
workspace_provider=edge_workspace_provider,
provision_provider=edge_provision_provider,
)
app.state.provider_default_node_id = local_provider_target.node_id
app.state.provider_default_transport_kind = local_provider_target.transport_kind
app.state.provider_default_runtime_kind = local_provider_target.runtime_kind
app.state.provider_default_core_adapter = local_provider_target.core_adapter
app.state.provider_registry = provider_registry
app.state.resolve_bot_provider_target = resolve_bot_provider_target_for_instance
app.state.resolve_edge_client = resolve_edge_client
app.state.edge_provision_provider = edge_provision_provider
app.state.edge_runtime_provider = edge_runtime_provider
app.state.edge_workspace_provider = edge_workspace_provider
app.state.provision_provider = local_provision_provider
app.state.runtime_provider = local_runtime_provider
app.state.workspace_provider = local_workspace_provider
def build_speech_transcription_runtime_service(
*,
data_root: str,
speech_service: Any,
get_speech_runtime_settings: Any,
logger: Any,
) -> SpeechTranscriptionService:
return SpeechTranscriptionService(
data_root=data_root,
speech_service=speech_service,
get_speech_runtime_settings=get_speech_runtime_settings,
logger=logger,
)
def build_image_runtime_service(
*,
cache: Any,
cache_key_images: Any,
invalidate_images_cache: Any,
docker_manager: Any,
reconcile_image_registry_fn: Any,
) -> ImageService:
return ImageService(
cache=cache,
cache_key_images=cache_key_images,
invalidate_images_cache=invalidate_images_cache,
reconcile_image_registry=reconcile_image_registry_fn,
docker_manager=docker_manager,
)
def build_system_runtime_service(
*,
engine: Any,
cache: Any,
database_engine: str,
redis_enabled: bool,
redis_url: str,
redis_prefix: str,
agent_md_templates_file: str,
topic_presets_templates_file: str,
default_soul_md: str,
default_agents_md: str,
default_user_md: str,
default_tools_md: str,
default_identity_md: str,
topic_preset_templates: Any,
get_default_system_timezone: Any,
load_agent_md_templates: Any,
load_topic_presets_template: Any,
get_platform_settings_snapshot: Any,
get_speech_runtime_settings: Any,
) -> SystemService:
return SystemService(
engine=engine,
cache=cache,
database_engine=database_engine,
redis_enabled=redis_enabled,
redis_url=redis_url,
redis_prefix=redis_prefix,
agent_md_templates_file=agent_md_templates_file,
topic_presets_templates_file=topic_presets_templates_file,
default_soul_md=default_soul_md,
default_agents_md=default_agents_md,
default_user_md=default_user_md,
default_tools_md=default_tools_md,
default_identity_md=default_identity_md,
topic_preset_templates=topic_preset_templates,
get_default_system_timezone=get_default_system_timezone,
load_agent_md_templates=load_agent_md_templates,
load_topic_presets_template=load_topic_presets_template,
get_platform_settings_snapshot=get_platform_settings_snapshot,
get_speech_runtime_settings=get_speech_runtime_settings,
)
def attach_runtime_services(
*,
app: Any,
bot_command_service: Any,
bot_lifecycle_service: Any,
app_lifecycle_service: Any,
bot_query_service: Any,
bot_channel_service: Any,
bot_message_service: Any,
bot_runtime_snapshot_service: Any,
image_service: Any,
provider_test_service: Any,
runtime_event_service: Any,
speech_transcription_service: Any,
system_service: Any,
workspace_service: Any,
runtime_service: Any,
) -> None:
app.state.bot_command_service = bot_command_service
app.state.bot_lifecycle_service = bot_lifecycle_service
app.state.app_lifecycle_service = app_lifecycle_service
app.state.bot_query_service = bot_query_service
app.state.bot_channel_service = bot_channel_service
app.state.bot_message_service = bot_message_service
app.state.bot_runtime_snapshot_service = bot_runtime_snapshot_service
app.state.image_service = image_service
app.state.provider_test_service = provider_test_service
app.state.runtime_event_service = runtime_event_service
app.state.speech_transcription_service = speech_transcription_service
app.state.system_service = system_service
app.state.workspace_service = workspace_service
app.state.runtime_service = runtime_service
def include_dashboard_api(
*,
app: Any,
image_service: Any,
provider_test_service: Any,
bot_lifecycle_service: Any,
bot_query_service: Any,
bot_channel_service: Any,
skill_service: Any,
bot_config_state_service: Any,
runtime_service: Any,
bot_message_service: Any,
workspace_service: Any,
speech_transcription_service: Any,
app_lifecycle_service: Any,
resolve_edge_state_context: Any,
logger: Any,
) -> None:
app.include_router(
build_dashboard_router(
image_service=image_service,
provider_test_service=provider_test_service,
bot_lifecycle_service=bot_lifecycle_service,
bot_query_service=bot_query_service,
bot_channel_service=bot_channel_service,
skill_service=skill_service,
bot_config_state_service=bot_config_state_service,
runtime_service=runtime_service,
bot_message_service=bot_message_service,
workspace_service=workspace_service,
speech_transcription_service=speech_transcription_service,
app_lifecycle_service=app_lifecycle_service,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
)

View File

@ -121,6 +121,17 @@ class EdgeClient(ABC):
) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def write_text_file(
self,
*,
bot_id: str,
path: str,
content: str,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
async def upload_files(
self,
@ -132,6 +143,16 @@ class EdgeClient(ABC):
) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def delete_workspace_path(
self,
*,
bot_id: str,
path: str,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
raise NotImplementedError
@abstractmethod
def serve_file(
self,

View File

@ -1,4 +1,5 @@
import mimetypes
import os
from typing import Any, Callable, Dict, List, Optional
from urllib.parse import quote
@ -234,6 +235,24 @@ class HttpEdgeClient(EdgeClient):
json=EdgeMarkdownWriteRequest(content=str(content or "")).model_dump(),
)
def write_text_file(
self,
*,
bot_id: str,
path: str,
content: str,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
params: Dict[str, Any] = {"path": path}
if workspace_root:
params["workspace_root"] = str(workspace_root).strip()
return self._request_json(
"PUT",
f"/api/edge/bots/{bot_id}/workspace/file/text",
params=params,
json=EdgeMarkdownWriteRequest(content=str(content or "")).model_dump(),
)
async def upload_files(
self,
*,
@ -275,6 +294,75 @@ class HttpEdgeClient(EdgeClient):
raise HTTPException(status_code=502, detail="dashboard-edge upload request failed before receiving a response")
return self._parse_json_response(response)
def delete_workspace_path(
self,
*,
bot_id: str,
path: str,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
params: Dict[str, Any] = {"path": path}
if workspace_root:
params["workspace_root"] = str(workspace_root).strip()
return self._request_json(
"DELETE",
f"/api/edge/bots/{bot_id}/workspace/file",
params=params,
)
def upload_local_files(
self,
*,
bot_id: str,
local_paths: List[str],
path: Optional[str] = None,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
if not local_paths:
return {"bot_id": bot_id, "files": []}
base_url = self._require_base_url()
multipart_files = []
handles = []
response: httpx.Response | None = None
try:
for local_path in local_paths:
normalized = os.path.abspath(os.path.expanduser(str(local_path or "").strip()))
if not os.path.isfile(normalized):
raise HTTPException(status_code=400, detail=f"Local upload file not found: {local_path}")
handle = open(normalized, "rb")
handles.append(handle)
multipart_files.append(
(
"files",
(
os.path.basename(normalized),
handle,
mimetypes.guess_type(normalized)[0] or "application/octet-stream",
),
)
)
with self._http_client_factory() as client:
response = client.request(
method="POST",
url=f"{base_url}/api/edge/bots/{quote(bot_id, safe='')}/workspace/upload",
headers=self._headers(),
params=self._workspace_upload_params(path=path, workspace_root=workspace_root),
files=multipart_files,
)
except OSError as exc:
raise HTTPException(status_code=500, detail=f"Failed to open local upload file: {exc.strerror or str(exc)}") from exc
except httpx.RequestError as exc:
raise edge_transport_http_exception(exc, node=self._node) from exc
finally:
for handle in handles:
try:
handle.close()
except Exception:
continue
if response is None:
raise HTTPException(status_code=502, detail="dashboard-edge upload request failed before receiving a response")
return self._parse_json_response(response)
def serve_file(
self,
*,

View File

@ -37,7 +37,6 @@ class BotConfigManager:
"qwen": "dashscope",
"aliyun-qwen": "dashscope",
"moonshot": "kimi",
"vllm": "openai",
# Xunfei Spark provides OpenAI-compatible endpoint.
"xunfei": "openai",
"iflytek": "openai",

View File

@ -15,7 +15,9 @@ from core.settings import (
from models import bot as _bot_models # noqa: F401
from models import platform as _platform_models # noqa: F401
from models import skill as _skill_models # noqa: F401
from models import sys_auth as _sys_auth_models # noqa: F401
from models import topic as _topic_models # noqa: F401
from services.sys_auth_service import seed_sys_auth
_engine_kwargs = {
"echo": DATABASE_ECHO,
@ -818,6 +820,8 @@ def init_database() -> None:
_cleanup_legacy_default_topics()
_drop_legacy_tables()
align_postgres_sequences()
with Session(engine) as session:
seed_sys_auth(session)
finally:
_release_migration_lock(lock_conn)

View File

@ -703,6 +703,12 @@ class BotDockerManager:
if response_match:
channel = response_match.group(1).strip().lower()
action_msg = response_match.group(2).strip()
if channel == "dashboard":
return {
"type": "ASSISTANT_MESSAGE",
"channel": "dashboard",
"text": action_msg[:4000],
}
return {
"type": "AGENT_STATE",
"channel": channel,

View File

@ -214,7 +214,11 @@ REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False)
REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip()
REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot"
REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400)
PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip()
JWT_ALGORITHM: Final[str] = "HS256"
JWT_SECRET: Final[str] = str(
os.getenv("JWT_SECRET")
or f"{PROJECT_ROOT.name}:{REDIS_PREFIX}:jwt"
).strip()
LEGACY_TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
TEMPLATE_ROOT: Final[Path] = (Path(DATA_ROOT) / "templates").resolve()

View File

@ -9,7 +9,7 @@ from pathlib import Path
from typing import Any, Dict, Optional
from core.settings import STT_DEVICE, STT_MODEL, STT_MODEL_DIR
from services.platform_service import get_speech_runtime_settings
from services.platform_settings_service import get_speech_runtime_settings
class SpeechServiceError(RuntimeError):

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,115 @@
from datetime import datetime
from typing import Optional
from sqlalchemy import UniqueConstraint
from sqlmodel import Field, SQLModel
class SysRole(SQLModel, table=True):
__tablename__ = "sys_role"
__table_args__ = (
UniqueConstraint("role_key", name="uq_sys_role_role_key"),
)
id: Optional[int] = Field(default=None, primary_key=True)
role_key: str = Field(index=True, max_length=64)
name: str = Field(default="", max_length=120)
description: str = Field(default="")
is_active: bool = Field(default=True, index=True)
sort_order: int = Field(default=100, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
class SysUser(SQLModel, table=True):
__tablename__ = "sys_user"
__table_args__ = (
UniqueConstraint("username", name="uq_sys_user_username"),
)
id: Optional[int] = Field(default=None, primary_key=True)
username: str = Field(index=True, max_length=64)
display_name: str = Field(default="", max_length=120)
password_hash: str = Field(default="", max_length=255)
password_salt: str = Field(default="", max_length=64)
role_id: Optional[int] = Field(default=None, foreign_key="sys_role.id", index=True)
is_active: bool = Field(default=True, index=True)
last_login_at: Optional[datetime] = Field(default=None, index=True)
current_token_hash: Optional[str] = Field(default=None, index=True, max_length=255)
current_token_expires_at: Optional[datetime] = Field(default=None, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
class SysMenu(SQLModel, table=True):
__tablename__ = "sys_menu"
__table_args__ = (
UniqueConstraint("menu_key", name="uq_sys_menu_menu_key"),
)
id: Optional[int] = Field(default=None, primary_key=True)
menu_key: str = Field(index=True, max_length=64)
parent_key: str = Field(default="", index=True, max_length=64)
title: str = Field(default="", max_length=120)
title_en: str = Field(default="", max_length=120)
menu_type: str = Field(default="item", max_length=32, index=True)
route_path: str = Field(default="", max_length=255)
icon: str = Field(default="", max_length=64)
permission_key: str = Field(default="", max_length=120)
visible: bool = Field(default=True, index=True)
sort_order: int = Field(default=100, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
class SysPermission(SQLModel, table=True):
__tablename__ = "sys_permission"
__table_args__ = (
UniqueConstraint("permission_key", name="uq_sys_permission_permission_key"),
)
id: Optional[int] = Field(default=None, primary_key=True)
permission_key: str = Field(index=True, max_length=120)
name: str = Field(default="", max_length=120)
menu_key: str = Field(default="", index=True, max_length=64)
action: str = Field(default="view", max_length=32, index=True)
description: str = Field(default="")
sort_order: int = Field(default=100, index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
updated_at: datetime = Field(default_factory=datetime.utcnow, index=True)
class SysRoleMenu(SQLModel, table=True):
__tablename__ = "sys_role_menu"
__table_args__ = (
UniqueConstraint("role_id", "menu_id", name="uq_sys_role_menu_role_menu"),
)
id: Optional[int] = Field(default=None, primary_key=True)
role_id: int = Field(foreign_key="sys_role.id", index=True)
menu_id: int = Field(foreign_key="sys_menu.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class SysRolePermission(SQLModel, table=True):
__tablename__ = "sys_role_permission"
__table_args__ = (
UniqueConstraint("role_id", "permission_id", name="uq_sys_role_permission_role_permission"),
)
id: Optional[int] = Field(default=None, primary_key=True)
role_id: int = Field(foreign_key="sys_role.id", index=True)
permission_id: int = Field(foreign_key="sys_permission.id", index=True)
created_at: datetime = Field(default_factory=datetime.utcnow)
class SysUserBot(SQLModel, table=True):
__tablename__ = "sys_user_bot"
__table_args__ = (
UniqueConstraint("user_id", "bot_id", name="uq_sys_user_bot_user_bot"),
)
id: Optional[int] = Field(default=None, primary_key=True)
user_id: int = Field(foreign_key="sys_user.id", index=True)
bot_id: str = Field(foreign_key="bot_instance.id", index=True, max_length=120)
created_at: datetime = Field(default_factory=datetime.utcnow)

View File

@ -7,7 +7,7 @@ from sqlmodel import Session
from models.bot import BotInstance
from providers.provision.base import ProvisionProvider
from providers.runtime.base import RuntimeProvider
from services.platform_service import record_activity_event
from services.platform_activity_service import record_activity_event
class LocalRuntimeProvider(RuntimeProvider):

View File

@ -15,5 +15,7 @@ watchfiles==0.21.0
urllib3==1.26.18
requests==2.31.0
redis==5.0.8
bcrypt==4.2.1
PyJWT==2.10.1
opencc-purepy==1.1.0
pywhispercpp==1.3.1

View File

@ -0,0 +1,122 @@
from typing import Any, Dict, List, Optional
from pydantic import BaseModel
class ChannelConfigRequest(BaseModel):
channel_type: str
external_app_id: Optional[str] = None
app_secret: Optional[str] = None
internal_port: Optional[int] = None
is_active: bool = True
extra_config: Optional[Dict[str, Any]] = None
class ChannelConfigUpdateRequest(BaseModel):
channel_type: Optional[str] = None
external_app_id: Optional[str] = None
app_secret: Optional[str] = None
internal_port: Optional[int] = None
is_active: Optional[bool] = None
extra_config: Optional[Dict[str, Any]] = None
class BotCreateRequest(BaseModel):
id: str
name: str
enabled: Optional[bool] = True
llm_provider: str
llm_model: str
api_key: str
image_tag: Optional[str] = None
system_prompt: Optional[str] = None
api_base: Optional[str] = None
temperature: float = 0.2
top_p: float = 1.0
max_tokens: int = 8192
cpu_cores: float = 1.0
memory_mb: int = 1024
storage_gb: int = 10
system_timezone: Optional[str] = None
soul_md: Optional[str] = None
agents_md: Optional[str] = None
user_md: Optional[str] = None
tools_md: Optional[str] = None
tools_config: Optional[Dict[str, Any]] = None
env_params: Optional[Dict[str, str]] = None
identity_md: Optional[str] = None
channels: Optional[List[ChannelConfigRequest]] = None
send_progress: Optional[bool] = None
send_tool_hints: Optional[bool] = None
node_id: Optional[str] = None
transport_kind: Optional[str] = None
runtime_kind: Optional[str] = None
core_adapter: Optional[str] = None
class BotUpdateRequest(BaseModel):
name: Optional[str] = None
enabled: Optional[bool] = None
llm_provider: Optional[str] = None
llm_model: Optional[str] = None
api_key: Optional[str] = None
api_base: Optional[str] = None
image_tag: Optional[str] = None
system_prompt: Optional[str] = None
temperature: Optional[float] = None
top_p: Optional[float] = None
max_tokens: Optional[int] = None
cpu_cores: Optional[float] = None
memory_mb: Optional[int] = None
storage_gb: Optional[int] = None
system_timezone: Optional[str] = None
soul_md: Optional[str] = None
agents_md: Optional[str] = None
user_md: Optional[str] = None
tools_md: Optional[str] = None
tools_config: Optional[Dict[str, Any]] = None
env_params: Optional[Dict[str, str]] = None
identity_md: Optional[str] = None
send_progress: Optional[bool] = None
send_tool_hints: Optional[bool] = None
node_id: Optional[str] = None
transport_kind: Optional[str] = None
runtime_kind: Optional[str] = None
core_adapter: Optional[str] = None
class BotDeployRequest(BaseModel):
node_id: str
runtime_kind: Optional[str] = None
image_tag: Optional[str] = None
auto_start: bool = False
class BotToolsConfigUpdateRequest(BaseModel):
tools_config: Optional[Dict[str, Any]] = None
class BotMcpConfigUpdateRequest(BaseModel):
mcp_servers: Optional[Dict[str, Any]] = None
class BotEnvParamsUpdateRequest(BaseModel):
env_params: Optional[Dict[str, str]] = None
class CommandRequest(BaseModel):
command: Optional[str] = None
attachments: Optional[List[str]] = None
class MessageFeedbackRequest(BaseModel):
feedback: Optional[str] = None
class WorkspaceFileUpdateRequest(BaseModel):
content: str
class SystemTemplatesUpdateRequest(BaseModel):
agent_md_templates: Optional[Dict[str, str]] = None
topic_presets: Optional[Dict[str, Any]] = None

View File

@ -75,6 +75,36 @@ class PlatformActivityItem(BaseModel):
created_at: str
class PlatformActivityListResponse(BaseModel):
items: List[PlatformActivityItem] = Field(default_factory=list)
total: int = 0
limit: int = 20
offset: int = 0
has_more: bool = False
class PlatformDashboardUsagePoint(BaseModel):
bucket_at: str
label: str
call_count: int = 0
class PlatformDashboardUsageSeries(BaseModel):
model: str
total_calls: int = 0
points: List[PlatformDashboardUsagePoint] = Field(default_factory=list)
class PlatformDashboardAnalyticsResponse(BaseModel):
total_request_count: int = 0
total_model_count: int = 0
granularity: str = "day"
since_days: int = 7
events_page_size: int = 20
series: List[PlatformDashboardUsageSeries] = Field(default_factory=list)
recent_events: List[PlatformActivityItem] = Field(default_factory=list)
class SystemSettingPayload(BaseModel):
key: str
name: str = ""

View File

@ -0,0 +1,153 @@
from typing import List, Optional
from pydantic import BaseModel, Field
class SysAuthLoginRequest(BaseModel):
username: str
password: str
class SysAuthMenuItem(BaseModel):
menu_key: str
parent_key: str = ""
title: str
title_en: str = ""
menu_type: str = "item"
route_path: str = ""
icon: str = ""
permission_key: str = ""
sort_order: int = 100
children: List["SysAuthMenuItem"] = Field(default_factory=list)
class SysAuthRolePayload(BaseModel):
id: int = 0
role_key: str
name: str
class SysAuthUserPayload(BaseModel):
id: int
username: str
display_name: str
role: Optional[SysAuthRolePayload] = None
class SysAssignedBotPayload(BaseModel):
id: str
name: str
enabled: bool = True
node_id: str = ""
node_display_name: str = ""
docker_status: str = "STOPPED"
image_tag: str = ""
class SysRoleSummaryResponse(BaseModel):
id: int
role_key: str
name: str
description: str = ""
is_active: bool = True
sort_order: int = 100
user_count: int = 0
menu_keys: List[str] = Field(default_factory=list)
permission_keys: List[str] = Field(default_factory=list)
class SysRoleListResponse(BaseModel):
items: List[SysRoleSummaryResponse] = Field(default_factory=list)
class SysRoleUpsertRequest(BaseModel):
role_key: str
name: str
description: str = ""
is_active: bool = True
sort_order: int = 100
menu_keys: List[str] = Field(default_factory=list)
permission_keys: List[str] = Field(default_factory=list)
class SysRoleGrantMenuItem(BaseModel):
menu_key: str
parent_key: str = ""
title: str
title_en: str = ""
menu_type: str = "item"
route_path: str = ""
icon: str = ""
sort_order: int = 100
children: List["SysRoleGrantMenuItem"] = Field(default_factory=list)
class SysPermissionSummaryResponse(BaseModel):
id: int
permission_key: str
name: str
menu_key: str = ""
action: str = "view"
description: str = ""
sort_order: int = 100
class SysRoleGrantBootstrapResponse(BaseModel):
menus: List[SysRoleGrantMenuItem] = Field(default_factory=list)
permissions: List[SysPermissionSummaryResponse] = Field(default_factory=list)
class SysUserSummaryResponse(BaseModel):
id: int
username: str
display_name: str
is_active: bool = True
last_login_at: Optional[str] = None
role: Optional[SysAuthRolePayload] = None
bot_ids: List[str] = Field(default_factory=list)
class SysUserListResponse(BaseModel):
items: List[SysUserSummaryResponse] = Field(default_factory=list)
class SysUserCreateRequest(BaseModel):
username: str
display_name: str
password: str
role_id: int
is_active: bool = True
bot_ids: List[str] = Field(default_factory=list)
class SysUserUpdateRequest(BaseModel):
display_name: str
password: str = ""
role_id: int
is_active: bool = True
bot_ids: List[str] = Field(default_factory=list)
class SysProfileUpdateRequest(BaseModel):
display_name: str
password: str = ""
class SysAuthBootstrapResponse(BaseModel):
token: str = ""
expires_at: Optional[str] = None
user: SysAuthUserPayload
menus: List[SysAuthMenuItem] = Field(default_factory=list)
permissions: List[str] = Field(default_factory=list)
home_path: str = "/dashboard"
assigned_bots: List[SysAssignedBotPayload] = Field(default_factory=list)
class SysAuthStatusResponse(BaseModel):
enabled: bool = True
user_count: int = 0
default_username: str = "admin"
SysAuthMenuItem.model_rebuild()
SysRoleGrantMenuItem.model_rebuild()

View File

@ -0,0 +1,165 @@
import asyncio
from typing import Any, Callable
from fastapi import HTTPException, WebSocket, WebSocketDisconnect
from sqlmodel import Session, select
from models.bot import BotInstance
from models.platform import BotRequestUsage
class AppLifecycleService:
def __init__(
self,
*,
app: Any,
engine: Any,
cache: Any,
logger: Any,
project_root: str,
database_engine: str,
database_echo: Any,
database_url_display: str,
redis_enabled: bool,
init_database: Callable[[], None],
node_registry_service: Any,
local_managed_node: Callable[[], Any],
prune_expired_activity_events: Callable[..., int],
migrate_bot_resources_store: Callable[[str], None],
resolve_bot_provider_target_for_instance: Callable[[Any], Any],
default_provider_target: Callable[[], Any],
set_bot_provider_target: Callable[[str, Any], None],
apply_provider_target_to_bot: Callable[[Any, Any], None],
normalize_provider_target: Callable[[Any], Any],
runtime_service: Any,
runtime_event_service: Any,
clear_provider_target_overrides: Callable[[], None],
) -> None:
self._app = app
self._engine = engine
self._cache = cache
self._logger = logger
self._project_root = project_root
self._database_engine = database_engine
self._database_echo = database_echo
self._database_url_display = database_url_display
self._redis_enabled = redis_enabled
self._init_database = init_database
self._node_registry_service = node_registry_service
self._local_managed_node = local_managed_node
self._prune_expired_activity_events = prune_expired_activity_events
self._migrate_bot_resources_store = migrate_bot_resources_store
self._resolve_bot_provider_target_for_instance = resolve_bot_provider_target_for_instance
self._default_provider_target = default_provider_target
self._set_bot_provider_target = set_bot_provider_target
self._apply_provider_target_to_bot = apply_provider_target_to_bot
self._normalize_provider_target = normalize_provider_target
self._runtime_service = runtime_service
self._runtime_event_service = runtime_event_service
self._clear_provider_target_overrides = clear_provider_target_overrides
async def on_startup(self) -> None:
self._app.state.main_loop = asyncio.get_running_loop()
self._clear_provider_target_overrides()
self._logger.info(
"startup project_root=%s db_engine=%s db_echo=%s db_url=%s redis=%s",
self._project_root,
self._database_engine,
self._database_echo,
self._database_url_display,
"enabled" if self._cache.ping() else ("disabled" if self._redis_enabled else "not_configured"),
)
self._init_database()
self._cache.delete_prefix("")
with Session(self._engine) as session:
self._node_registry_service.load_from_session(session)
self._node_registry_service.upsert_node(session, self._local_managed_node())
pruned_events = self._prune_expired_activity_events(session, force=True)
if pruned_events > 0:
session.commit()
target_dirty = False
for bot in session.exec(select(BotInstance)).all():
self._migrate_bot_resources_store(bot.id)
target = self._resolve_bot_provider_target_for_instance(bot)
if str(target.transport_kind or "").strip().lower() != "edge":
target = self._normalize_provider_target(
{
"node_id": target.node_id,
"transport_kind": "edge",
"runtime_kind": target.runtime_kind,
"core_adapter": target.core_adapter,
},
fallback=self._default_provider_target(),
)
self._set_bot_provider_target(bot.id, target)
if (
str(getattr(bot, "node_id", "") or "").strip().lower() != target.node_id
or str(getattr(bot, "transport_kind", "") or "").strip().lower() != target.transport_kind
or str(getattr(bot, "runtime_kind", "") or "").strip().lower() != target.runtime_kind
or str(getattr(bot, "core_adapter", "") or "").strip().lower() != target.core_adapter
):
self._apply_provider_target_to_bot(bot, target)
session.add(bot)
target_dirty = True
if target_dirty:
session.commit()
running_bots = session.exec(select(BotInstance).where(BotInstance.docker_status == "RUNNING")).all()
for bot in running_bots:
try:
self._runtime_service.ensure_monitor(app_state=self._app.state, bot=bot)
pending_usage = session.exec(
select(BotRequestUsage)
.where(BotRequestUsage.bot_id == str(bot.id or "").strip())
.where(BotRequestUsage.status == "PENDING")
.order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc())
.limit(1)
).first()
if pending_usage and str(getattr(pending_usage, "request_id", "") or "").strip():
self._runtime_service.sync_edge_monitor_packets(
app_state=self._app.state,
bot=bot,
request_id=str(pending_usage.request_id or "").strip(),
)
except HTTPException as exc:
self._logger.warning(
"Skip runtime monitor restore on startup for bot_id=%s due to unavailable runtime backend: %s",
str(bot.id or ""),
str(getattr(exc, "detail", "") or exc),
)
except Exception:
self._logger.exception("Failed to restore runtime monitor on startup for bot_id=%s", str(bot.id or ""))
async def handle_websocket(self, websocket: WebSocket, bot_id: str) -> None:
with Session(self._engine) as session:
bot = session.get(BotInstance, bot_id)
if not bot:
await websocket.close(code=4404, reason="Bot not found")
return
connected = False
try:
await self._runtime_event_service.manager.connect(bot_id, websocket)
connected = True
except Exception as exc:
self._logger.warning("websocket connect failed bot_id=%s detail=%s", bot_id, exc)
try:
await websocket.close(code=1011, reason="WebSocket accept failed")
except Exception:
pass
return
self._runtime_service.ensure_monitor(app_state=websocket.app.state, bot=bot)
try:
while True:
await websocket.receive_text()
except WebSocketDisconnect:
pass
except RuntimeError as exc:
msg = str(exc or "").lower()
if "need to call \"accept\" first" not in msg and "not connected" not in msg:
self._logger.exception("websocket runtime error bot_id=%s", bot_id)
except Exception:
self._logger.exception("websocket unexpected error bot_id=%s", bot_id)
finally:
if connected:
self._runtime_event_service.manager.disconnect(bot_id, websocket)

View File

@ -0,0 +1,175 @@
from typing import Any, Callable, Dict, List
from fastapi import HTTPException
from sqlmodel import Session
from models.bot import BotInstance
ReadBotConfig = Callable[[str], Dict[str, Any]]
WriteBotConfig = Callable[[str, Dict[str, Any]], None]
SyncBotWorkspace = Callable[[Session, BotInstance], None]
InvalidateBotCache = Callable[[str], None]
GetBotChannels = Callable[[BotInstance], List[Dict[str, Any]]]
NormalizeChannelExtra = Callable[[Any], Dict[str, Any]]
ChannelApiToCfg = Callable[[Dict[str, Any]], Dict[str, Any]]
ReadGlobalDeliveryFlags = Callable[[Any], tuple[bool, bool]]
class BotChannelService:
def __init__(
self,
*,
read_bot_config: ReadBotConfig,
write_bot_config: WriteBotConfig,
sync_bot_workspace_via_provider: SyncBotWorkspace,
invalidate_bot_detail_cache: InvalidateBotCache,
get_bot_channels_from_config: GetBotChannels,
normalize_channel_extra: NormalizeChannelExtra,
channel_api_to_cfg: ChannelApiToCfg,
read_global_delivery_flags: ReadGlobalDeliveryFlags,
) -> None:
self._read_bot_config = read_bot_config
self._write_bot_config = write_bot_config
self._sync_bot_workspace_via_provider = sync_bot_workspace_via_provider
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
self._get_bot_channels_from_config = get_bot_channels_from_config
self._normalize_channel_extra = normalize_channel_extra
self._channel_api_to_cfg = channel_api_to_cfg
self._read_global_delivery_flags = read_global_delivery_flags
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def list_channels(self, *, session: Session, bot_id: str) -> List[Dict[str, Any]]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self._get_bot_channels_from_config(bot)
def create_channel(self, *, session: Session, bot_id: str, payload: Any) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
ctype = str(getattr(payload, "channel_type", "") or "").strip().lower()
if not ctype:
raise HTTPException(status_code=400, detail="channel_type is required")
if ctype == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be created manually")
current_rows = self._get_bot_channels_from_config(bot)
if any(str(row.get("channel_type") or "").lower() == ctype for row in current_rows):
raise HTTPException(status_code=400, detail=f"Channel already exists: {ctype}")
new_row = {
"id": ctype,
"bot_id": bot_id,
"channel_type": ctype,
"external_app_id": str(getattr(payload, "external_app_id", "") or "").strip() or f"{ctype}-{bot_id}",
"app_secret": str(getattr(payload, "app_secret", "") or "").strip(),
"internal_port": max(1, min(int(getattr(payload, "internal_port", 8080) or 8080), 65535)),
"is_active": bool(getattr(payload, "is_active", True)),
"extra_config": self._normalize_channel_extra(getattr(payload, "extra_config", None)),
"locked": False,
}
config_data = self._read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg[ctype] = self._channel_api_to_cfg(new_row)
self._write_bot_config(bot_id, config_data)
self._sync_bot_workspace_via_provider(session, bot)
self._invalidate_bot_detail_cache(bot_id)
return new_row
def update_channel(
self,
*,
session: Session,
bot_id: str,
channel_id: str,
payload: Any,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
channel_key = str(channel_id or "").strip().lower()
rows = self._get_bot_channels_from_config(bot)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").strip().lower() == "dashboard" or bool(row.get("locked")):
raise HTTPException(status_code=400, detail="dashboard channel is built-in and cannot be modified")
update_data = payload.model_dump(exclude_unset=True)
existing_type = str(row.get("channel_type") or "").strip().lower()
new_type = existing_type
if "channel_type" in update_data and update_data["channel_type"] is not None:
new_type = str(update_data["channel_type"]).strip().lower()
if not new_type:
raise HTTPException(status_code=400, detail="channel_type cannot be empty")
if existing_type == "dashboard" and new_type != "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel type cannot be changed")
if new_type != existing_type and any(str(r.get("channel_type") or "").lower() == new_type for r in rows):
raise HTTPException(status_code=400, detail=f"Channel already exists: {new_type}")
if "external_app_id" in update_data and update_data["external_app_id"] is not None:
row["external_app_id"] = str(update_data["external_app_id"]).strip()
if "app_secret" in update_data and update_data["app_secret"] is not None:
row["app_secret"] = str(update_data["app_secret"]).strip()
if "internal_port" in update_data and update_data["internal_port"] is not None:
row["internal_port"] = max(1, min(int(update_data["internal_port"]), 65535))
if "is_active" in update_data and update_data["is_active"] is not None:
next_active = bool(update_data["is_active"])
if existing_type == "dashboard" and not next_active:
raise HTTPException(status_code=400, detail="dashboard channel must remain enabled")
row["is_active"] = next_active
if "extra_config" in update_data:
row["extra_config"] = self._normalize_channel_extra(update_data.get("extra_config"))
row["channel_type"] = new_type
row["id"] = new_type
row["locked"] = new_type == "dashboard"
config_data = self._read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
current_send_progress, current_send_tool_hints = self._read_global_delivery_flags(channels_cfg)
if new_type == "dashboard":
extra = self._normalize_channel_extra(row.get("extra_config"))
channels_cfg["sendProgress"] = bool(extra.get("sendProgress", current_send_progress))
channels_cfg["sendToolHints"] = bool(extra.get("sendToolHints", current_send_tool_hints))
else:
channels_cfg["sendProgress"] = current_send_progress
channels_cfg["sendToolHints"] = current_send_tool_hints
channels_cfg.pop("dashboard", None)
if existing_type != "dashboard" and existing_type in channels_cfg and existing_type != new_type:
channels_cfg.pop(existing_type, None)
if new_type != "dashboard":
channels_cfg[new_type] = self._channel_api_to_cfg(row)
self._write_bot_config(bot_id, config_data)
session.commit()
self._sync_bot_workspace_via_provider(session, bot)
self._invalidate_bot_detail_cache(bot_id)
return row
def delete_channel(self, *, session: Session, bot_id: str, channel_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
channel_key = str(channel_id or "").strip().lower()
rows = self._get_bot_channels_from_config(bot)
row = next((r for r in rows if str(r.get("id") or "").lower() == channel_key), None)
if not row:
raise HTTPException(status_code=404, detail="Channel not found")
if str(row.get("channel_type") or "").lower() == "dashboard":
raise HTTPException(status_code=400, detail="dashboard channel cannot be deleted")
config_data = self._read_bot_config(bot_id)
channels_cfg = config_data.get("channels")
if not isinstance(channels_cfg, dict):
channels_cfg = {}
config_data["channels"] = channels_cfg
channels_cfg.pop(str(row.get("channel_type") or "").lower(), None)
self._write_bot_config(bot_id, config_data)
session.commit()
self._sync_bot_workspace_via_provider(session, bot)
self._invalidate_bot_detail_cache(bot_id)
return {"status": "deleted"}

View File

@ -0,0 +1,320 @@
import json
import os
import re
from datetime import datetime
from typing import Any, Callable, Dict, List
from fastapi import HTTPException
from sqlmodel import Session
from models.bot import BotInstance
ReadEdgeStateData = Callable[..., Dict[str, Any]]
WriteEdgeStateData = Callable[..., bool]
ReadBotConfig = Callable[[str], Dict[str, Any]]
WriteBotConfig = Callable[[str, Dict[str, Any]], None]
InvalidateBotCache = Callable[[str], None]
PathResolver = Callable[[str], str]
NormalizeEnvParams = Callable[[Any], Dict[str, str]]
class BotConfigStateService:
_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$")
def __init__(
self,
*,
read_edge_state_data: ReadEdgeStateData,
write_edge_state_data: WriteEdgeStateData,
read_bot_config: ReadBotConfig,
write_bot_config: WriteBotConfig,
invalidate_bot_detail_cache: InvalidateBotCache,
env_store_path: PathResolver,
cron_store_path: PathResolver,
normalize_env_params: NormalizeEnvParams,
) -> None:
self._read_edge_state_data = read_edge_state_data
self._write_edge_state_data = write_edge_state_data
self._read_bot_config = read_bot_config
self._write_bot_config = write_bot_config
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
self._env_store_path = env_store_path
self._cron_store_path = cron_store_path
self._normalize_env_params = normalize_env_params
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def read_env_store(self, bot_id: str) -> Dict[str, str]:
data = self._read_edge_state_data(bot_id=bot_id, state_key="env", default_payload={})
if data:
return self._normalize_env_params(data)
path = self._env_store_path(bot_id)
if not os.path.isfile(path):
return {}
try:
with open(path, "r", encoding="utf-8") as file:
payload = json.load(file)
return self._normalize_env_params(payload)
except Exception:
return {}
def write_env_store(self, bot_id: str, env_params: Dict[str, str]) -> None:
normalized_env = self._normalize_env_params(env_params)
if self._write_edge_state_data(bot_id=bot_id, state_key="env", data=normalized_env):
return
path = self._env_store_path(bot_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(normalized_env, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def get_env_params(self, bot_id: str) -> Dict[str, Any]:
return {
"bot_id": bot_id,
"env_params": self.read_env_store(bot_id),
}
def get_env_params_for_bot(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.get_env_params(bot_id)
def update_env_params(self, bot_id: str, env_params: Any) -> Dict[str, Any]:
normalized = self._normalize_env_params(env_params)
self.write_env_store(bot_id, normalized)
self._invalidate_bot_detail_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"env_params": normalized,
"restart_required": True,
}
def update_env_params_for_bot(self, *, session: Session, bot_id: str, env_params: Any) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.update_env_params(bot_id, env_params)
def normalize_mcp_servers(self, raw: Any) -> Dict[str, Dict[str, Any]]:
if not isinstance(raw, dict):
return {}
rows: Dict[str, Dict[str, Any]] = {}
for server_name, server_cfg in raw.items():
name = str(server_name or "").strip()
if not name or not self._MCP_SERVER_NAME_RE.fullmatch(name):
continue
if not isinstance(server_cfg, dict):
continue
url = str(server_cfg.get("url") or "").strip()
if not url:
continue
transport_type = str(server_cfg.get("type") or "streamableHttp").strip()
if transport_type not in {"streamableHttp", "sse"}:
transport_type = "streamableHttp"
headers_raw = server_cfg.get("headers")
headers: Dict[str, str] = {}
if isinstance(headers_raw, dict):
for key, value in headers_raw.items():
header_key = str(key or "").strip()
if not header_key:
continue
headers[header_key] = str(value or "").strip()
timeout_raw = server_cfg.get("toolTimeout", 60)
try:
timeout = int(timeout_raw)
except Exception:
timeout = 60
timeout = max(1, min(timeout, 600))
rows[name] = {
"type": transport_type,
"url": url,
"headers": headers,
"toolTimeout": timeout,
}
return rows
def _merge_mcp_servers_preserving_extras(
self,
current_raw: Any,
normalized: Dict[str, Dict[str, Any]],
) -> Dict[str, Dict[str, Any]]:
current_map = current_raw if isinstance(current_raw, dict) else {}
merged: Dict[str, Dict[str, Any]] = {}
for name, normalized_cfg in normalized.items():
base = current_map.get(name)
base_cfg = dict(base) if isinstance(base, dict) else {}
next_cfg = dict(base_cfg)
next_cfg.update(normalized_cfg)
merged[name] = next_cfg
return merged
def _sanitize_mcp_servers_in_config_data(self, config_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]:
if not isinstance(config_data, dict):
return {}
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
tools_cfg = {}
current_raw = tools_cfg.get("mcpServers")
normalized = self.normalize_mcp_servers(current_raw)
merged = self._merge_mcp_servers_preserving_extras(current_raw, normalized)
tools_cfg["mcpServers"] = merged
config_data["tools"] = tools_cfg
return merged
def get_mcp_config(self, bot_id: str) -> Dict[str, Any]:
config_data = self._read_bot_config(bot_id)
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
if not isinstance(tools_cfg, dict):
tools_cfg = {}
mcp_servers = self.normalize_mcp_servers(tools_cfg.get("mcpServers"))
return {
"bot_id": bot_id,
"mcp_servers": mcp_servers,
"locked_servers": [],
"restart_required": True,
}
def get_mcp_config_for_bot(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.get_mcp_config(bot_id)
def update_mcp_config(self, bot_id: str, mcp_servers: Any) -> Dict[str, Any]:
config_data = self._read_bot_config(bot_id)
if not isinstance(config_data, dict):
config_data = {}
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
tools_cfg = {}
normalized_mcp_servers = self.normalize_mcp_servers(mcp_servers or {})
current_mcp_servers = tools_cfg.get("mcpServers")
merged_mcp_servers = self._merge_mcp_servers_preserving_extras(current_mcp_servers, normalized_mcp_servers)
tools_cfg["mcpServers"] = merged_mcp_servers
config_data["tools"] = tools_cfg
sanitized_after_save = self._sanitize_mcp_servers_in_config_data(config_data)
self._write_bot_config(bot_id, config_data)
self._invalidate_bot_detail_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"mcp_servers": self.normalize_mcp_servers(sanitized_after_save),
"locked_servers": [],
"restart_required": True,
}
def update_mcp_config_for_bot(self, *, session: Session, bot_id: str, mcp_servers: Any) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.update_mcp_config(bot_id, mcp_servers)
def read_cron_store(self, bot_id: str) -> Dict[str, Any]:
data = self._read_edge_state_data(
bot_id=bot_id,
state_key="cron",
default_payload={"version": 1, "jobs": []},
)
if isinstance(data, dict) and data:
jobs = data.get("jobs")
if not isinstance(jobs, list):
jobs = []
try:
version = int(data.get("version", 1) or 1)
except Exception:
version = 1
return {"version": max(1, version), "jobs": jobs}
path = self._cron_store_path(bot_id)
if not os.path.isfile(path):
return {"version": 1, "jobs": []}
try:
with open(path, "r", encoding="utf-8") as file:
payload = json.load(file)
if not isinstance(payload, dict):
return {"version": 1, "jobs": []}
jobs = payload.get("jobs")
if not isinstance(jobs, list):
payload["jobs"] = []
if "version" not in payload:
payload["version"] = 1
return payload
except Exception:
return {"version": 1, "jobs": []}
def write_cron_store(self, bot_id: str, store: Dict[str, Any]) -> None:
normalized_store = dict(store if isinstance(store, dict) else {})
jobs = normalized_store.get("jobs")
if not isinstance(jobs, list):
normalized_store["jobs"] = []
try:
normalized_store["version"] = max(1, int(normalized_store.get("version", 1) or 1))
except Exception:
normalized_store["version"] = 1
if self._write_edge_state_data(bot_id=bot_id, state_key="cron", data=normalized_store):
return
path = self._cron_store_path(bot_id)
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp_path = f"{path}.tmp"
with open(tmp_path, "w", encoding="utf-8") as file:
json.dump(normalized_store, file, ensure_ascii=False, indent=2)
os.replace(tmp_path, path)
def list_cron_jobs(self, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
store = self.read_cron_store(bot_id)
rows = []
for row in store.get("jobs", []):
if not isinstance(row, dict):
continue
enabled = bool(row.get("enabled", True))
if not include_disabled and not enabled:
continue
rows.append(row)
rows.sort(key=lambda value: int(((value.get("state") or {}).get("nextRunAtMs")) or 2**62))
return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows}
def list_cron_jobs_for_bot(self, *, session: Session, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.list_cron_jobs(bot_id, include_disabled=include_disabled)
def stop_cron_job(self, bot_id: str, job_id: str) -> Dict[str, Any]:
store = self.read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
found = None
for row in jobs:
if isinstance(row, dict) and str(row.get("id")) == job_id:
found = row
break
if not found:
raise HTTPException(status_code=404, detail="Cron job not found")
found["enabled"] = False
found["updatedAtMs"] = int(datetime.utcnow().timestamp() * 1000)
self.write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs})
return {"status": "stopped", "job_id": job_id}
def stop_cron_job_for_bot(self, *, session: Session, bot_id: str, job_id: str) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.stop_cron_job(bot_id, job_id)
def delete_cron_job(self, bot_id: str, job_id: str) -> Dict[str, Any]:
store = self.read_cron_store(bot_id)
jobs = store.get("jobs", [])
if not isinstance(jobs, list):
jobs = []
kept = [row for row in jobs if not (isinstance(row, dict) and str(row.get("id")) == job_id)]
if len(kept) == len(jobs):
raise HTTPException(status_code=404, detail="Cron job not found")
self.write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept})
return {"status": "deleted", "job_id": job_id}
def delete_cron_job_for_bot(self, *, session: Session, bot_id: str, job_id: str) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.delete_cron_job(bot_id, job_id)

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,611 @@
import logging
import os
import re
import shutil
from datetime import datetime
from typing import Any, Callable, Dict, Optional
from fastapi import HTTPException
from sqlmodel import Session, select
from core.settings import (
BOTS_WORKSPACE_ROOT,
DEFAULT_AGENTS_MD,
DEFAULT_IDENTITY_MD,
DEFAULT_SOUL_MD,
DEFAULT_TOOLS_MD,
DEFAULT_USER_MD,
)
from models.bot import BotInstance, BotMessage
from models.platform import BotActivityEvent, BotRequestUsage
from models.skill import BotSkillInstall
from models.topic import TopicItem, TopicTopic
from providers.target import ProviderTarget, normalize_provider_target, provider_target_to_dict
from services.runtime_service import RuntimeService
RefreshBotRuntimeStatus = Callable[[Any, BotInstance], str]
ResolveBotProviderTarget = Callable[[BotInstance], ProviderTarget]
ProviderTargetFromNode = Callable[[Optional[str]], Optional[ProviderTarget]]
DefaultProviderTarget = Callable[[], ProviderTarget]
EnsureProviderTargetSupported = Callable[[ProviderTarget], None]
RequireReadyImage = Callable[..., Any]
SyncBotWorkspaceViaProvider = Callable[..., None]
ApplyProviderTargetToBot = Callable[[BotInstance, ProviderTarget], None]
SerializeProviderTargetSummary = Callable[[ProviderTarget], Dict[str, Any]]
SerializeBot = Callable[[BotInstance], Dict[str, Any]]
NodeDisplayName = Callable[[str], str]
InvalidateBotCache = Callable[[str], None]
RecordActivityEvent = Callable[..., None]
NormalizeEnvParams = Callable[[Any], Dict[str, str]]
NormalizeSystemTimezone = Callable[[Any], str]
NormalizeResourceLimits = Callable[[Any, Any, Any], Dict[str, Any]]
WriteEnvStore = Callable[[str, Dict[str, str]], None]
ResolveBotEnvParams = Callable[[str], Dict[str, str]]
ClearProviderTargetOverride = Callable[[str], None]
NormalizeInitialChannels = Callable[[str, Any], Any]
ExpectedEdgeOfflineError = Callable[[Exception], bool]
SummarizeEdgeException = Callable[[Exception], str]
ResolveEdgeClient = Callable[[ProviderTarget], Any]
NodeMetadata = Callable[[str], Dict[str, Any]]
LogEdgeFailure = Callable[..., None]
InvalidateBotMessagesCache = Callable[[str], None]
class BotLifecycleService:
def __init__(
self,
*,
bot_id_pattern: re.Pattern[str],
runtime_service: RuntimeService,
refresh_bot_runtime_status: RefreshBotRuntimeStatus,
resolve_bot_provider_target: ResolveBotProviderTarget,
provider_target_from_node: ProviderTargetFromNode,
default_provider_target: DefaultProviderTarget,
ensure_provider_target_supported: EnsureProviderTargetSupported,
require_ready_image: RequireReadyImage,
sync_bot_workspace_via_provider: SyncBotWorkspaceViaProvider,
apply_provider_target_to_bot: ApplyProviderTargetToBot,
serialize_provider_target_summary: SerializeProviderTargetSummary,
serialize_bot: SerializeBot,
node_display_name: NodeDisplayName,
invalidate_bot_detail_cache: InvalidateBotCache,
record_activity_event: RecordActivityEvent,
normalize_env_params: NormalizeEnvParams,
normalize_system_timezone: NormalizeSystemTimezone,
normalize_resource_limits: NormalizeResourceLimits,
write_env_store: WriteEnvStore,
resolve_bot_env_params: ResolveBotEnvParams,
clear_provider_target_override: ClearProviderTargetOverride,
normalize_initial_channels: NormalizeInitialChannels,
is_expected_edge_offline_error: ExpectedEdgeOfflineError,
summarize_edge_exception: SummarizeEdgeException,
resolve_edge_client: ResolveEdgeClient,
node_metadata: NodeMetadata,
log_edge_failure: LogEdgeFailure,
invalidate_bot_messages_cache: InvalidateBotMessagesCache,
logger: logging.Logger,
) -> None:
self._bot_id_pattern = bot_id_pattern
self._runtime_service = runtime_service
self._refresh_bot_runtime_status = refresh_bot_runtime_status
self._resolve_bot_provider_target = resolve_bot_provider_target
self._provider_target_from_node = provider_target_from_node
self._default_provider_target = default_provider_target
self._ensure_provider_target_supported = ensure_provider_target_supported
self._require_ready_image = require_ready_image
self._sync_bot_workspace_via_provider = sync_bot_workspace_via_provider
self._apply_provider_target_to_bot = apply_provider_target_to_bot
self._serialize_provider_target_summary = serialize_provider_target_summary
self._serialize_bot = serialize_bot
self._node_display_name = node_display_name
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
self._record_activity_event = record_activity_event
self._normalize_env_params = normalize_env_params
self._normalize_system_timezone = normalize_system_timezone
self._normalize_resource_limits = normalize_resource_limits
self._write_env_store = write_env_store
self._resolve_bot_env_params = resolve_bot_env_params
self._clear_provider_target_override = clear_provider_target_override
self._normalize_initial_channels = normalize_initial_channels
self._is_expected_edge_offline_error = is_expected_edge_offline_error
self._summarize_edge_exception = summarize_edge_exception
self._resolve_edge_client = resolve_edge_client
self._node_metadata = node_metadata
self._log_edge_failure = log_edge_failure
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
self._logger = logger
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def create_bot(self, *, session: Session, payload: Any) -> Dict[str, Any]:
normalized_bot_id = str(getattr(payload, "id", "") or "").strip()
if not normalized_bot_id:
raise HTTPException(status_code=400, detail="Bot ID is required")
if not self._bot_id_pattern.fullmatch(normalized_bot_id):
raise HTTPException(status_code=400, detail="Bot ID can only contain letters, numbers, and underscores")
if session.get(BotInstance, normalized_bot_id):
raise HTTPException(status_code=409, detail=f"Bot ID already exists: {normalized_bot_id}")
normalized_env_params = self._normalize_env_params(getattr(payload, "env_params", None))
try:
normalized_env_params["TZ"] = self._normalize_system_timezone(getattr(payload, "system_timezone", None))
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
provider_target = normalize_provider_target(
{
"node_id": getattr(payload, "node_id", None),
"transport_kind": getattr(payload, "transport_kind", None),
"runtime_kind": getattr(payload, "runtime_kind", None),
"core_adapter": getattr(payload, "core_adapter", None),
},
fallback=self._provider_target_from_node(getattr(payload, "node_id", None)) or self._default_provider_target(),
)
self._ensure_provider_target_supported(provider_target)
normalized_image_tag = str(getattr(payload, "image_tag", "") or "").strip()
if provider_target.runtime_kind == "docker":
self._require_ready_image(session, normalized_image_tag, require_local_image=True)
bot = BotInstance(
id=normalized_bot_id,
name=getattr(payload, "name", None),
enabled=bool(getattr(payload, "enabled", True)) if getattr(payload, "enabled", None) is not None else True,
access_password="",
image_tag=normalized_image_tag,
node_id=provider_target.node_id,
transport_kind=provider_target.transport_kind,
runtime_kind=provider_target.runtime_kind,
core_adapter=provider_target.core_adapter,
workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id),
)
session.add(bot)
session.commit()
session.refresh(bot)
resource_limits = self._normalize_resource_limits(
getattr(payload, "cpu_cores", None),
getattr(payload, "memory_mb", None),
getattr(payload, "storage_gb", None),
)
workspace_synced = True
sync_error_detail = ""
try:
self._write_env_store(normalized_bot_id, normalized_env_params)
self._sync_bot_workspace_via_provider(
session,
bot,
target_override=provider_target,
channels_override=self._normalize_initial_channels(normalized_bot_id, getattr(payload, "channels", None)),
global_delivery_override={
"sendProgress": bool(getattr(payload, "send_progress", None))
if getattr(payload, "send_progress", None) is not None
else False,
"sendToolHints": bool(getattr(payload, "send_tool_hints", None))
if getattr(payload, "send_tool_hints", None) is not None
else False,
},
runtime_overrides={
"llm_provider": getattr(payload, "llm_provider", None),
"llm_model": getattr(payload, "llm_model", None),
"api_key": getattr(payload, "api_key", None),
"api_base": getattr(payload, "api_base", "") or "",
"temperature": getattr(payload, "temperature", None),
"top_p": getattr(payload, "top_p", None),
"max_tokens": getattr(payload, "max_tokens", None),
"cpu_cores": resource_limits["cpu_cores"],
"memory_mb": resource_limits["memory_mb"],
"storage_gb": resource_limits["storage_gb"],
"node_id": provider_target.node_id,
"transport_kind": provider_target.transport_kind,
"runtime_kind": provider_target.runtime_kind,
"core_adapter": provider_target.core_adapter,
"system_prompt": getattr(payload, "system_prompt", None) or getattr(payload, "soul_md", None) or DEFAULT_SOUL_MD,
"soul_md": getattr(payload, "soul_md", None) or getattr(payload, "system_prompt", None) or DEFAULT_SOUL_MD,
"agents_md": getattr(payload, "agents_md", None) or DEFAULT_AGENTS_MD,
"user_md": getattr(payload, "user_md", None) or DEFAULT_USER_MD,
"tools_md": getattr(payload, "tools_md", None) or DEFAULT_TOOLS_MD,
"identity_md": getattr(payload, "identity_md", None) or DEFAULT_IDENTITY_MD,
"send_progress": bool(getattr(payload, "send_progress", None))
if getattr(payload, "send_progress", None) is not None
else False,
"send_tool_hints": bool(getattr(payload, "send_tool_hints", None))
if getattr(payload, "send_tool_hints", None) is not None
else False,
},
)
except Exception as exc:
if self._is_expected_edge_offline_error(exc):
workspace_synced = False
sync_error_detail = self._summarize_edge_exception(exc)
self._logger.info(
"Create bot pending sync due to offline edge bot_id=%s node=%s detail=%s",
normalized_bot_id,
provider_target.node_id,
sync_error_detail,
)
else:
detail = self._summarize_edge_exception(exc)
try:
doomed = session.get(BotInstance, normalized_bot_id)
if doomed is not None:
session.delete(doomed)
session.commit()
self._clear_provider_target_override(normalized_bot_id)
except Exception:
session.rollback()
raise HTTPException(status_code=502, detail=f"Failed to initialize bot workspace: {detail}") from exc
session.refresh(bot)
self._record_activity_event(
session,
normalized_bot_id,
"bot_created",
channel="system",
detail=f"Bot {normalized_bot_id} created",
metadata={
"image_tag": normalized_image_tag,
"workspace_synced": workspace_synced,
"sync_error": sync_error_detail if not workspace_synced else "",
},
)
if not workspace_synced:
self._record_activity_event(
session,
normalized_bot_id,
"bot_warning",
channel="system",
detail="Bot created, but node is offline. Workspace sync is pending.",
metadata={"sync_error": sync_error_detail, "node_id": provider_target.node_id},
)
session.commit()
self._invalidate_bot_detail_cache(normalized_bot_id)
return self._serialize_bot(bot)
def update_bot(self, *, session: Session, bot_id: str, payload: Any) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
update_data = payload.model_dump(exclude_unset=True)
env_params = update_data.pop("env_params", None) if isinstance(update_data, dict) else None
system_timezone = update_data.pop("system_timezone", None) if isinstance(update_data, dict) else None
normalized_system_timezone: Optional[str] = None
if system_timezone is not None:
try:
normalized_system_timezone = self._normalize_system_timezone(system_timezone)
except ValueError as exc:
raise HTTPException(status_code=400, detail=str(exc)) from exc
runtime_overrides: Dict[str, Any] = {}
update_data.pop("tools_config", None) if isinstance(update_data, dict) else None
runtime_fields = {
"llm_provider",
"llm_model",
"api_key",
"api_base",
"temperature",
"top_p",
"max_tokens",
"cpu_cores",
"memory_mb",
"storage_gb",
"soul_md",
"agents_md",
"user_md",
"tools_md",
"identity_md",
"send_progress",
"send_tool_hints",
"system_prompt",
}
execution_target_fields = {
"node_id",
"transport_kind",
"runtime_kind",
"core_adapter",
}
deploy_only_fields = {"image_tag", *execution_target_fields}
if deploy_only_fields & set(update_data.keys()):
raise HTTPException(
status_code=400,
detail=f"Use /api/bots/{bot_id}/deploy for execution target or image changes",
)
for field in runtime_fields:
if field in update_data:
runtime_overrides[field] = update_data.pop(field)
next_target: Optional[ProviderTarget] = None
for text_field in ("llm_provider", "llm_model", "api_key"):
if text_field in runtime_overrides:
text = str(runtime_overrides.get(text_field) or "").strip()
if not text:
runtime_overrides.pop(text_field, None)
else:
runtime_overrides[text_field] = text
if "api_base" in runtime_overrides:
runtime_overrides["api_base"] = str(runtime_overrides.get("api_base") or "").strip()
if "system_prompt" in runtime_overrides and "soul_md" not in runtime_overrides:
runtime_overrides["soul_md"] = runtime_overrides["system_prompt"]
if "soul_md" in runtime_overrides and "system_prompt" not in runtime_overrides:
runtime_overrides["system_prompt"] = runtime_overrides["soul_md"]
if {"cpu_cores", "memory_mb", "storage_gb"} & set(runtime_overrides.keys()):
normalized_resources = self._normalize_resource_limits(
runtime_overrides.get("cpu_cores"),
runtime_overrides.get("memory_mb"),
runtime_overrides.get("storage_gb"),
)
runtime_overrides.update(normalized_resources)
db_fields = {"name", "enabled"}
for key, value in update_data.items():
if key in db_fields:
setattr(bot, key, value)
previous_env_params: Optional[Dict[str, str]] = None
next_env_params: Optional[Dict[str, str]] = None
if env_params is not None or normalized_system_timezone is not None:
previous_env_params = self._resolve_bot_env_params(bot_id)
next_env_params = dict(previous_env_params)
if env_params is not None:
next_env_params = self._normalize_env_params(env_params)
if normalized_system_timezone is not None:
next_env_params["TZ"] = normalized_system_timezone
global_delivery_override: Optional[Dict[str, Any]] = None
if "send_progress" in runtime_overrides or "send_tool_hints" in runtime_overrides:
global_delivery_override = {}
if "send_progress" in runtime_overrides:
global_delivery_override["sendProgress"] = bool(runtime_overrides.get("send_progress"))
if "send_tool_hints" in runtime_overrides:
global_delivery_override["sendToolHints"] = bool(runtime_overrides.get("send_tool_hints"))
self._sync_bot_workspace_via_provider(
session,
bot,
target_override=next_target,
runtime_overrides=runtime_overrides if runtime_overrides else None,
global_delivery_override=global_delivery_override,
)
try:
if next_env_params is not None:
self._write_env_store(bot_id, next_env_params)
if next_target is not None:
self._apply_provider_target_to_bot(bot, next_target)
session.add(bot)
session.commit()
except Exception:
session.rollback()
if previous_env_params is not None:
self._write_env_store(bot_id, previous_env_params)
raise
session.refresh(bot)
self._invalidate_bot_detail_cache(bot_id)
return self._serialize_bot(bot)
async def start_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return await self._runtime_service.start_bot(app_state=app_state, session=session, bot=bot)
def stop_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self._runtime_service.stop_bot(app_state=app_state, session=session, bot=bot)
def enable_bot(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
bot.enabled = True
session.add(bot)
self._record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled")
session.commit()
self._invalidate_bot_detail_cache(bot_id)
return {"status": "enabled", "enabled": True}
def disable_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
self._set_inactive(app_state=app_state, session=session, bot=bot, activity_type="bot_disabled", detail="disabled")
return {"status": "disabled", "enabled": False}
def deactivate_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
self._set_inactive(
app_state=app_state,
session=session,
bot=bot,
activity_type="bot_deactivated",
detail="deactivated",
)
return {"status": "deactivated"}
def delete_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
delete_workspace: bool = True,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
target = self._resolve_bot_provider_target(bot)
try:
self._runtime_service.stop_bot(app_state=app_state, session=session, bot=bot)
except Exception:
pass
workspace_deleted = not bool(delete_workspace)
if delete_workspace:
if target.transport_kind == "edge":
try:
workspace_root = str(self._node_metadata(target.node_id).get("workspace_root") or "").strip() or None
purge_result = self._resolve_edge_client(target).purge_workspace(
bot_id=bot_id,
workspace_root=workspace_root,
)
workspace_deleted = str(purge_result.get("status") or "").strip().lower() in {"deleted", "not_found"}
except Exception as exc:
self._log_edge_failure(
self._logger,
key=f"bot-delete-workspace:{bot_id}",
exc=exc,
message=f"Failed to purge edge workspace for bot_id={bot_id}",
)
workspace_deleted = False
workspace_root = os.path.join(BOTS_WORKSPACE_ROOT, bot_id)
if os.path.isdir(workspace_root):
shutil.rmtree(workspace_root, ignore_errors=True)
workspace_deleted = True
messages = session.exec(select(BotMessage).where(BotMessage.bot_id == bot_id)).all()
for row in messages:
session.delete(row)
topic_items = session.exec(select(TopicItem).where(TopicItem.bot_id == bot_id)).all()
for row in topic_items:
session.delete(row)
topics = session.exec(select(TopicTopic).where(TopicTopic.bot_id == bot_id)).all()
for row in topics:
session.delete(row)
usage_rows = session.exec(select(BotRequestUsage).where(BotRequestUsage.bot_id == bot_id)).all()
for row in usage_rows:
session.delete(row)
activity_rows = session.exec(select(BotActivityEvent).where(BotActivityEvent.bot_id == bot_id)).all()
for row in activity_rows:
session.delete(row)
skill_install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
for row in skill_install_rows:
session.delete(row)
session.delete(bot)
session.commit()
self._clear_provider_target_override(bot_id)
self._invalidate_bot_detail_cache(bot_id)
self._invalidate_bot_messages_cache(bot_id)
return {"status": "deleted", "workspace_deleted": workspace_deleted}
async def deploy_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
node_id: str,
runtime_kind: Optional[str] = None,
image_tag: Optional[str] = None,
auto_start: bool = False,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
actual_status = self._refresh_bot_runtime_status(app_state, bot)
session.add(bot)
session.commit()
if actual_status == "RUNNING":
raise HTTPException(status_code=409, detail="Stop the bot before deploy or migrate")
current_target = self._resolve_bot_provider_target(bot)
next_target_base = self._provider_target_from_node(node_id)
if next_target_base is None:
raise HTTPException(status_code=400, detail=f"Managed node not found: {node_id}")
next_target = normalize_provider_target(
{
"node_id": node_id,
"runtime_kind": runtime_kind,
},
fallback=next_target_base,
)
self._ensure_provider_target_supported(next_target)
existing_image_tag = str(bot.image_tag or "").strip()
requested_image_tag = str(image_tag or "").strip()
if next_target.runtime_kind == "docker":
requested_image_tag = requested_image_tag or existing_image_tag
image_changed = requested_image_tag != str(bot.image_tag or "").strip()
target_changed = next_target.key != current_target.key
if not image_changed and not target_changed:
raise HTTPException(status_code=400, detail="No deploy changes detected")
if next_target.runtime_kind == "docker":
self._require_ready_image(
session,
requested_image_tag,
require_local_image=True,
)
self._sync_bot_workspace_via_provider(
session,
bot,
target_override=next_target,
runtime_overrides=provider_target_to_dict(next_target),
)
previous_image_tag = str(bot.image_tag or "").strip()
bot.image_tag = requested_image_tag
self._apply_provider_target_to_bot(bot, next_target)
bot.updated_at = datetime.utcnow()
session.add(bot)
self._record_activity_event(
session,
bot_id,
"bot_deployed",
channel="system",
detail=(
f"Bot {bot_id} deployed to {self._node_display_name(next_target.node_id)}"
if target_changed
else f"Bot {bot_id} redeployed with image {requested_image_tag}"
),
metadata={
"previous_target": self._serialize_provider_target_summary(current_target),
"next_target": self._serialize_provider_target_summary(next_target),
"previous_image_tag": previous_image_tag,
"image_tag": requested_image_tag,
"auto_start": bool(auto_start),
},
)
session.commit()
session.refresh(bot)
started = False
if bool(auto_start):
await self._runtime_service.start_bot(app_state=app_state, session=session, bot=bot)
session.refresh(bot)
started = True
self._invalidate_bot_detail_cache(bot_id)
return {
"status": "deployed",
"bot": self._serialize_bot(bot),
"started": started,
"image_tag": requested_image_tag,
"previous_image_tag": previous_image_tag,
"previous_target": self._serialize_provider_target_summary(current_target),
"next_target": self._serialize_provider_target_summary(next_target),
}
def _set_inactive(
self,
*,
app_state: Any,
session: Session,
bot: BotInstance,
activity_type: str,
detail: str,
) -> None:
bot_id = str(bot.id or "").strip()
try:
self._runtime_service.stop_bot(app_state=app_state, session=session, bot=bot)
except Exception:
pass
bot.enabled = False
bot.docker_status = "STOPPED"
if str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
session.add(bot)
self._record_activity_event(session, bot_id, activity_type, channel="system", detail=f"Bot {bot_id} {detail}")
session.commit()
self._invalidate_bot_detail_cache(bot_id)

View File

@ -0,0 +1,246 @@
from datetime import datetime
from typing import Any, Callable, Dict, Optional
from fastapi import HTTPException
from sqlmodel import Session, select
from models.bot import BotInstance, BotMessage
CacheKeyMessages = Callable[[str, int], str]
CacheKeyMessagesPage = Callable[[str, int, Optional[int]], str]
SerializeMessageRow = Callable[[str, BotMessage], Dict[str, Any]]
ResolveLocalDayRange = Callable[[str, Optional[int]], tuple[datetime, datetime]]
InvalidateMessagesCache = Callable[[str], None]
GetChatPullPageSize = Callable[[], int]
class BotMessageService:
def __init__(
self,
*,
cache: Any,
cache_key_bot_messages: CacheKeyMessages,
cache_key_bot_messages_page: CacheKeyMessagesPage,
serialize_bot_message_row: SerializeMessageRow,
resolve_local_day_range: ResolveLocalDayRange,
invalidate_bot_messages_cache: InvalidateMessagesCache,
get_chat_pull_page_size: GetChatPullPageSize,
) -> None:
self._cache = cache
self._cache_key_bot_messages = cache_key_bot_messages
self._cache_key_bot_messages_page = cache_key_bot_messages_page
self._serialize_bot_message_row = serialize_bot_message_row
self._resolve_local_day_range = resolve_local_day_range
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
self._get_chat_pull_page_size = get_chat_pull_page_size
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def list_messages(self, *, session: Session, bot_id: str, limit: int = 200) -> list[Dict[str, Any]]:
self._require_bot(session=session, bot_id=bot_id)
safe_limit = max(1, min(int(limit), 500))
cached = self._cache.get_json(self._cache_key_bot_messages(bot_id, safe_limit))
if isinstance(cached, list):
return cached
rows = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(safe_limit)
).all()
ordered = list(reversed(rows))
payload = [self._serialize_bot_message_row(bot_id, row) for row in ordered]
self._cache.set_json(self._cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30)
return payload
def list_messages_page(
self,
*,
session: Session,
bot_id: str,
limit: Optional[int] = None,
before_id: Optional[int] = None,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
configured_limit = self._get_chat_pull_page_size()
safe_limit = max(1, min(int(limit if limit is not None else configured_limit), 500))
safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None
cache_key = self._cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id)
cached = self._cache.get_json(cache_key)
if isinstance(cached, dict) and isinstance(cached.get("items"), list):
return cached
stmt = (
select(BotMessage)
.where(BotMessage.bot_id == bot_id)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(safe_limit + 1)
)
if safe_before_id is not None:
stmt = stmt.where(BotMessage.id < safe_before_id)
rows = session.exec(stmt).all()
has_more = len(rows) > safe_limit
if has_more:
rows = rows[:safe_limit]
ordered = list(reversed(rows))
items = [self._serialize_bot_message_row(bot_id, row) for row in ordered]
next_before_id = rows[-1].id if rows else None
payload = {
"items": items,
"has_more": bool(has_more),
"next_before_id": next_before_id,
"limit": safe_limit,
}
self._cache.set_json(cache_key, payload, ttl=30)
return payload
def list_messages_by_date(
self,
*,
session: Session,
bot_id: str,
date: str,
tz_offset_minutes: Optional[int] = None,
limit: Optional[int] = None,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
utc_start, utc_end = self._resolve_local_day_range(date, tz_offset_minutes)
configured_limit = max(60, self._get_chat_pull_page_size())
safe_limit = max(12, min(int(limit if limit is not None else configured_limit), 240))
before_limit = max(3, min(18, safe_limit // 4))
after_limit = max(0, safe_limit - before_limit - 1)
exact_anchor = session.exec(
select(BotMessage)
.where(
BotMessage.bot_id == bot_id,
BotMessage.created_at >= utc_start,
BotMessage.created_at < utc_end,
)
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
.limit(1)
).first()
anchor = exact_anchor
matched_exact_date = exact_anchor is not None
if anchor is None:
next_row = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.created_at >= utc_end)
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
.limit(1)
).first()
prev_row = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.created_at < utc_start)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(1)
).first()
if next_row and prev_row:
gap_after = next_row.created_at - utc_end
gap_before = utc_start - prev_row.created_at
anchor = next_row if gap_after <= gap_before else prev_row
else:
anchor = next_row or prev_row
if anchor is None or anchor.id is None:
return {
"items": [],
"anchor_id": None,
"resolved_ts": None,
"matched_exact_date": False,
"has_more_before": False,
"has_more_after": False,
}
before_rows = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.id < anchor.id)
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
.limit(before_limit)
).all()
after_rows = session.exec(
select(BotMessage)
.where(BotMessage.bot_id == bot_id, BotMessage.id > anchor.id)
.order_by(BotMessage.created_at.asc(), BotMessage.id.asc())
.limit(after_limit)
).all()
ordered = list(reversed(before_rows)) + [anchor] + after_rows
first_row = ordered[0] if ordered else None
last_row = ordered[-1] if ordered else None
has_more_before = False
if first_row is not None and first_row.id is not None:
has_more_before = (
session.exec(
select(BotMessage.id)
.where(BotMessage.bot_id == bot_id, BotMessage.id < first_row.id)
.order_by(BotMessage.id.desc())
.limit(1)
).first()
is not None
)
has_more_after = False
if last_row is not None and last_row.id is not None:
has_more_after = (
session.exec(
select(BotMessage.id)
.where(BotMessage.bot_id == bot_id, BotMessage.id > last_row.id)
.order_by(BotMessage.id.asc())
.limit(1)
).first()
is not None
)
return {
"items": [self._serialize_bot_message_row(bot_id, row) for row in ordered],
"anchor_id": anchor.id,
"resolved_ts": int(anchor.created_at.timestamp() * 1000),
"matched_exact_date": matched_exact_date,
"has_more_before": has_more_before,
"has_more_after": has_more_after,
}
def update_feedback(
self,
*,
session: Session,
bot_id: str,
message_id: int,
feedback: Optional[str],
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
row = session.get(BotMessage, message_id)
if not row or row.bot_id != bot_id:
raise HTTPException(status_code=404, detail="Message not found")
if row.role != "assistant":
raise HTTPException(status_code=400, detail="Only assistant messages support feedback")
raw = str(feedback or "").strip().lower()
if raw in {"", "none", "null"}:
row.feedback = None
row.feedback_at = None
elif raw in {"up", "down"}:
row.feedback = raw
row.feedback_at = datetime.utcnow()
else:
raise HTTPException(status_code=400, detail="feedback must be 'up' or 'down'")
session.add(row)
session.commit()
self._invalidate_bot_messages_cache(bot_id)
return {
"status": "updated",
"bot_id": bot_id,
"message_id": row.id,
"feedback": row.feedback,
"feedback_at": row.feedback_at.isoformat() if row.feedback_at else None,
}

View File

@ -0,0 +1,180 @@
from datetime import datetime
from typing import Any, Callable, Dict, Optional
from fastapi import HTTPException
from sqlmodel import Session
from clients.edge.errors import log_edge_failure
from models.bot import BotInstance
CacheKeyBotsList = Callable[[Optional[int]], str]
CacheKeyBotDetail = Callable[[str], str]
RefreshBotRuntimeStatus = Callable[[Any, BotInstance], str]
SerializeBot = Callable[[BotInstance], Dict[str, Any]]
SerializeBotListItem = Callable[[BotInstance], Dict[str, Any]]
ReadBotResources = Callable[[str], Dict[str, Any]]
ResolveBotProviderTarget = Callable[[BotInstance], Any]
WorkspaceRoot = Callable[[str], str]
CalcDirSizeBytes = Callable[[str], int]
class BotQueryService:
def __init__(
self,
*,
cache: Any,
cache_key_bots_list: CacheKeyBotsList,
cache_key_bot_detail: CacheKeyBotDetail,
refresh_bot_runtime_status: RefreshBotRuntimeStatus,
serialize_bot: SerializeBot,
serialize_bot_list_item: SerializeBotListItem,
read_bot_resources: ReadBotResources,
resolve_bot_provider_target: ResolveBotProviderTarget,
get_runtime_provider: Callable[[Any, BotInstance], Any],
workspace_root: WorkspaceRoot,
calc_dir_size_bytes: CalcDirSizeBytes,
logger: Any,
) -> None:
self._cache = cache
self._cache_key_bots_list = cache_key_bots_list
self._cache_key_bot_detail = cache_key_bot_detail
self._refresh_bot_runtime_status = refresh_bot_runtime_status
self._serialize_bot = serialize_bot
self._serialize_bot_list_item = serialize_bot_list_item
self._read_bot_resources = read_bot_resources
self._resolve_bot_provider_target = resolve_bot_provider_target
self._get_runtime_provider = get_runtime_provider
self._workspace_root = workspace_root
self._calc_dir_size_bytes = calc_dir_size_bytes
self._logger = logger
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def list_bots(self, *, app_state: Any, session: Session, current_user_id: int) -> list[Dict[str, Any]]:
from models.sys_auth import SysUser
from services.sys_auth_service import list_accessible_bots_for_user
cached = self._cache.get_json(self._cache_key_bots_list(current_user_id))
if isinstance(cached, list):
return cached
current_user = session.get(SysUser, current_user_id) if current_user_id > 0 else None
if current_user is None:
raise HTTPException(status_code=401, detail="Authentication required")
bots = list_accessible_bots_for_user(session, current_user)
dirty = False
for bot in bots:
previous_status = str(bot.docker_status or "").upper()
previous_state = str(bot.current_state or "")
actual_status = self._refresh_bot_runtime_status(app_state, bot)
if previous_status != actual_status or previous_state != str(bot.current_state or ""):
session.add(bot)
dirty = True
if dirty:
session.commit()
for bot in bots:
session.refresh(bot)
rows = [self._serialize_bot_list_item(bot) for bot in bots]
self._cache.set_json(self._cache_key_bots_list(current_user_id), rows, ttl=30)
return rows
def get_bot_detail(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
cached = self._cache.get_json(self._cache_key_bot_detail(bot_id))
if isinstance(cached, dict):
return cached
bot = self._require_bot(session=session, bot_id=bot_id)
previous_status = str(bot.docker_status or "").upper()
previous_state = str(bot.current_state or "")
actual_status = self._refresh_bot_runtime_status(app_state, bot)
if previous_status != actual_status or previous_state != str(bot.current_state or ""):
session.add(bot)
session.commit()
session.refresh(bot)
row = self._serialize_bot(bot)
self._cache.set_json(self._cache_key_bot_detail(bot_id), row, ttl=30)
return row
def get_bot_resources(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
configured = self._read_bot_resources(bot_id)
try:
runtime = self._get_runtime_provider(app_state, bot).get_resource_snapshot(bot_id=bot_id)
except Exception as exc:
log_edge_failure(
self._logger,
key=f"bot-resources:{bot_id}",
exc=exc,
message=f"Failed to refresh bot resources for bot_id={bot_id}",
)
runtime = {"usage": {}, "limits": {}, "docker_status": str(bot.docker_status or "STOPPED").upper()}
runtime_status = str(runtime.get("docker_status") or "").upper()
previous_status = str(bot.docker_status or "").upper()
previous_state = str(bot.current_state or "")
if runtime_status:
bot.docker_status = runtime_status
if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
if previous_status != str(bot.docker_status or "").upper() or previous_state != str(bot.current_state or ""):
session.add(bot)
session.commit()
session.refresh(bot)
target = self._resolve_bot_provider_target(bot)
usage_payload = dict(runtime.get("usage") or {})
workspace_bytes = int(usage_payload.get("container_rw_bytes") or usage_payload.get("workspace_used_bytes") or 0)
workspace_root = ""
if workspace_bytes <= 0:
workspace_root = self._workspace_root(bot_id)
workspace_bytes = self._calc_dir_size_bytes(workspace_root)
elif target.transport_kind != "edge":
workspace_root = self._workspace_root(bot_id)
configured_storage_bytes = int(configured.get("storage_gb", 0) or 0) * 1024 * 1024 * 1024
workspace_percent = 0.0
if configured_storage_bytes > 0:
workspace_percent = (workspace_bytes / configured_storage_bytes) * 100.0
limits = runtime.get("limits") or {}
cpu_limited = (limits.get("cpu_cores") or 0) > 0
memory_limited = (limits.get("memory_bytes") or 0) > 0
storage_limited = bool(limits.get("storage_bytes")) or bool(limits.get("storage_opt_raw"))
return {
"bot_id": bot_id,
"docker_status": runtime.get("docker_status") or bot.docker_status,
"configured": configured,
"runtime": runtime,
"workspace": {
"path": workspace_root or None,
"usage_bytes": workspace_bytes,
"configured_limit_bytes": configured_storage_bytes if configured_storage_bytes > 0 else None,
"usage_percent": max(0.0, workspace_percent),
},
"enforcement": {
"cpu_limited": cpu_limited,
"memory_limited": memory_limited,
"storage_limited": storage_limited,
},
"note": (
"Resource value 0 means unlimited. CPU/Memory limits come from Docker HostConfig and are enforced by cgroup. "
"Storage limit depends on Docker storage driver support."
),
"collected_at": datetime.utcnow().isoformat() + "Z",
}
def get_tools_config(self, *, session: Session, bot_id: str) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return {
"bot_id": bot_id,
"tools_config": {},
"managed_by_dashboard": False,
"hint": "Tools config is disabled in dashboard. Configure tool-related env vars manually.",
}
def update_tools_config(self, *, session: Session, bot_id: str, payload: Any) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
raise HTTPException(
status_code=400,
detail="Tools config is no longer managed by dashboard. Please set required env vars manually.",
)

View File

@ -0,0 +1,288 @@
import asyncio
import os
import time
from typing import Any, Callable, Dict
from sqlmodel import Session
from clients.edge.errors import log_edge_failure
from models.bot import BotInstance
from providers.target import provider_target_to_dict
class BotRuntimeSnapshotService:
_AGENT_LOOP_READY_MARKER = "Agent loop started"
def __init__(
self,
*,
engine: Any,
logger: Any,
docker_manager: Any,
default_soul_md: str,
default_agents_md: str,
default_user_md: str,
default_tools_md: str,
default_identity_md: str,
workspace_root: Callable[[str], str],
resolve_edge_state_context: Callable[[str], Any],
read_bot_config: Callable[[str], Dict[str, Any]],
resolve_bot_env_params: Callable[[str], Dict[str, str]],
resolve_bot_provider_target_for_instance: Callable[[BotInstance], Any],
read_global_delivery_flags: Callable[[Any], tuple[bool, bool]],
safe_float: Callable[[Any, float], float],
safe_int: Callable[[Any, int], int],
get_default_system_timezone: Callable[[], str],
read_bot_resources: Callable[[str, Any], Dict[str, Any]],
node_display_name: Callable[[str], str],
get_runtime_provider: Callable[[Any, BotInstance], Any],
invalidate_bot_detail_cache: Callable[[str], None],
record_activity_event: Callable[..., None],
) -> None:
self._engine = engine
self._logger = logger
self._docker_manager = docker_manager
self._default_soul_md = default_soul_md
self._default_agents_md = default_agents_md
self._default_user_md = default_user_md
self._default_tools_md = default_tools_md
self._default_identity_md = default_identity_md
self._workspace_root = workspace_root
self._resolve_edge_state_context = resolve_edge_state_context
self._read_bot_config = read_bot_config
self._resolve_bot_env_params = resolve_bot_env_params
self._resolve_bot_provider_target_for_instance = resolve_bot_provider_target_for_instance
self._read_global_delivery_flags = read_global_delivery_flags
self._safe_float = safe_float
self._safe_int = safe_int
self._get_default_system_timezone = get_default_system_timezone
self._read_bot_resources = read_bot_resources
self._node_display_name = node_display_name
self._get_runtime_provider = get_runtime_provider
self._invalidate_bot_detail_cache = invalidate_bot_detail_cache
self._record_activity_event = record_activity_event
def read_workspace_md(self, bot_id: str, filename: str, default_value: str) -> str:
edge_context = self._resolve_edge_state_context(bot_id)
if edge_context is not None:
client, workspace_root, node_id = edge_context
try:
payload = client.read_file(
bot_id=bot_id,
path=filename,
max_bytes=1_000_000,
workspace_root=workspace_root,
)
if bool(payload.get("is_markdown")):
content = payload.get("content")
if isinstance(content, str):
return content.strip()
except Exception as exc:
log_edge_failure(
self._logger,
key=f"workspace-md-read:{node_id}:{bot_id}:{filename}",
exc=exc,
message=f"Failed to read edge workspace markdown for bot_id={bot_id}, file={filename}",
)
return default_value
path = os.path.join(self._workspace_root(bot_id), filename)
if not os.path.isfile(path):
return default_value
try:
with open(path, "r", encoding="utf-8") as file:
return file.read().strip()
except Exception:
return default_value
def read_bot_runtime_snapshot(self, bot: BotInstance) -> Dict[str, Any]:
config_data = self._read_bot_config(bot.id)
env_params = self._resolve_bot_env_params(bot.id)
target = self._resolve_bot_provider_target_for_instance(bot)
provider_name = ""
provider_cfg: Dict[str, Any] = {}
providers_cfg = config_data.get("providers")
if isinstance(providers_cfg, dict):
for p_name, p_cfg in providers_cfg.items():
provider_name = str(p_name or "").strip()
if isinstance(p_cfg, dict):
provider_cfg = p_cfg
break
agents_defaults: Dict[str, Any] = {}
agents_cfg = config_data.get("agents")
if isinstance(agents_cfg, dict):
defaults = agents_cfg.get("defaults")
if isinstance(defaults, dict):
agents_defaults = defaults
channels_cfg = config_data.get("channels")
send_progress, send_tool_hints = self._read_global_delivery_flags(channels_cfg)
llm_provider = provider_name or "dashscope"
llm_model = str(agents_defaults.get("model") or "")
api_key = str(provider_cfg.get("apiKey") or "").strip()
api_base = str(provider_cfg.get("apiBase") or "").strip()
api_base_lower = api_base.lower()
if llm_provider == "openai" and ("spark-api-open.xf-yun.com" in api_base_lower or "xf-yun.com" in api_base_lower):
llm_provider = "xunfei"
soul_md = self.read_workspace_md(bot.id, "SOUL.md", self._default_soul_md)
resources = self._read_bot_resources(bot.id, config_data=config_data)
return {
**provider_target_to_dict(target),
"llm_provider": llm_provider,
"llm_model": llm_model,
"api_key": api_key,
"api_base": api_base,
"temperature": self._safe_float(agents_defaults.get("temperature"), 0.2),
"top_p": self._safe_float(agents_defaults.get("topP"), 1.0),
"max_tokens": self._safe_int(agents_defaults.get("maxTokens"), 8192),
"cpu_cores": resources["cpu_cores"],
"memory_mb": resources["memory_mb"],
"storage_gb": resources["storage_gb"],
"system_timezone": env_params.get("TZ") or self._get_default_system_timezone(),
"send_progress": send_progress,
"send_tool_hints": send_tool_hints,
"soul_md": soul_md,
"agents_md": self.read_workspace_md(bot.id, "AGENTS.md", self._default_agents_md),
"user_md": self.read_workspace_md(bot.id, "USER.md", self._default_user_md),
"tools_md": self.read_workspace_md(bot.id, "TOOLS.md", self._default_tools_md),
"identity_md": self.read_workspace_md(bot.id, "IDENTITY.md", self._default_identity_md),
"system_prompt": soul_md,
}
def serialize_bot(self, bot: BotInstance) -> Dict[str, Any]:
runtime = self.read_bot_runtime_snapshot(bot)
target = self._resolve_bot_provider_target_for_instance(bot)
return {
"id": bot.id,
"name": bot.name,
"enabled": bool(getattr(bot, "enabled", True)),
"avatar_model": "base",
"avatar_skin": "blue_suit",
"image_tag": bot.image_tag,
"llm_provider": runtime.get("llm_provider") or "",
"llm_model": runtime.get("llm_model") or "",
"system_prompt": runtime.get("system_prompt") or "",
"api_base": runtime.get("api_base") or "",
"temperature": self._safe_float(runtime.get("temperature"), 0.2),
"top_p": self._safe_float(runtime.get("top_p"), 1.0),
"max_tokens": self._safe_int(runtime.get("max_tokens"), 8192),
"cpu_cores": self._safe_float(runtime.get("cpu_cores"), 1.0),
"memory_mb": self._safe_int(runtime.get("memory_mb"), 1024),
"storage_gb": self._safe_int(runtime.get("storage_gb"), 10),
"system_timezone": str(runtime.get("system_timezone") or self._get_default_system_timezone()),
"send_progress": bool(runtime.get("send_progress")),
"send_tool_hints": bool(runtime.get("send_tool_hints")),
"node_id": target.node_id,
"node_display_name": self._node_display_name(target.node_id),
"transport_kind": target.transport_kind,
"runtime_kind": target.runtime_kind,
"core_adapter": target.core_adapter,
"soul_md": runtime.get("soul_md") or "",
"agents_md": runtime.get("agents_md") or "",
"user_md": runtime.get("user_md") or "",
"tools_md": runtime.get("tools_md") or "",
"identity_md": runtime.get("identity_md") or "",
"workspace_dir": bot.workspace_dir,
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
"created_at": bot.created_at,
"updated_at": bot.updated_at,
}
def serialize_bot_list_item(self, bot: BotInstance) -> Dict[str, Any]:
runtime = self.read_bot_runtime_snapshot(bot)
target = self._resolve_bot_provider_target_for_instance(bot)
return {
"id": bot.id,
"name": bot.name,
"enabled": bool(getattr(bot, "enabled", True)),
"image_tag": bot.image_tag,
"llm_provider": runtime.get("llm_provider") or "",
"llm_model": runtime.get("llm_model") or "",
"node_id": target.node_id,
"node_display_name": self._node_display_name(target.node_id),
"transport_kind": target.transport_kind,
"runtime_kind": target.runtime_kind,
"core_adapter": target.core_adapter,
"docker_status": bot.docker_status,
"current_state": bot.current_state,
"last_action": bot.last_action,
"updated_at": bot.updated_at,
}
def refresh_bot_runtime_status(self, app_state: Any, bot: BotInstance) -> str:
current_status = str(bot.docker_status or "STOPPED").upper()
try:
status = str(self._get_runtime_provider(app_state, bot).get_runtime_status(bot_id=str(bot.id or "")) or "STOPPED").upper()
except Exception as exc:
log_edge_failure(
self._logger,
key=f"bot-runtime-status:{bot.id}",
exc=exc,
message=f"Failed to refresh runtime status for bot_id={bot.id}",
)
return current_status
bot.docker_status = status
if status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}:
bot.current_state = "IDLE"
return status
async def wait_for_agent_loop_ready(
self,
bot_id: str,
timeout_seconds: float = 12.0,
poll_interval_seconds: float = 0.5,
) -> bool:
deadline = time.monotonic() + max(1.0, timeout_seconds)
marker = self._AGENT_LOOP_READY_MARKER.lower()
while time.monotonic() < deadline:
logs = self._docker_manager.get_recent_logs(bot_id, tail=200)
if any(marker in str(line or "").lower() for line in logs):
return True
await asyncio.sleep(max(0.1, poll_interval_seconds))
return False
async def record_agent_loop_ready_warning(
self,
bot_id: str,
timeout_seconds: float = 12.0,
poll_interval_seconds: float = 0.5,
) -> None:
try:
agent_loop_ready = await self.wait_for_agent_loop_ready(
bot_id,
timeout_seconds=timeout_seconds,
poll_interval_seconds=poll_interval_seconds,
)
if agent_loop_ready:
return
if self._docker_manager.get_bot_status(bot_id) != "RUNNING":
return
detail = (
"Bot container started, but ready marker was not found in logs within "
f"{int(timeout_seconds)}s. Check bot logs or MCP config if the bot stays unavailable."
)
self._logger.warning("bot_id=%s agent loop ready marker not found within %ss", bot_id, timeout_seconds)
with Session(self._engine) as background_session:
if not background_session.get(BotInstance, bot_id):
return
self._record_activity_event(
background_session,
bot_id,
"bot_warning",
channel="system",
detail=detail,
metadata={
"kind": "agent_loop_ready_timeout",
"marker": self._AGENT_LOOP_READY_MARKER,
"timeout_seconds": timeout_seconds,
},
)
background_session.commit()
self._invalidate_bot_detail_cache(bot_id)
except Exception:
self._logger.exception("Failed to record agent loop readiness warning for bot_id=%s", bot_id)

View File

@ -0,0 +1,129 @@
from typing import Any, Callable, Optional
from urllib.parse import unquote
from fastapi import Request
from fastapi.responses import JSONResponse
from sqlmodel import Session
from models.bot import BotInstance
class DashboardAuthService:
AUTH_TOKEN_HEADER = "authorization"
AUTH_TOKEN_FALLBACK_HEADER = "x-auth-token"
def __init__(self, *, engine: Any) -> None:
self._engine = engine
def extract_bot_id_from_api_path(self, path: str) -> Optional[str]:
raw = str(path or "").strip()
if not raw.startswith("/api/bots/"):
return None
rest = raw[len("/api/bots/") :]
if not rest:
return None
bot_id_segment = rest.split("/", 1)[0].strip()
if not bot_id_segment:
return None
try:
decoded = unquote(bot_id_segment)
except Exception:
decoded = bot_id_segment
return str(decoded).strip() or None
def get_supplied_auth_token_http(self, request: Request) -> str:
auth_header = str(request.headers.get(self.AUTH_TOKEN_HEADER) or "").strip()
if auth_header.lower().startswith("bearer "):
token = auth_header[7:].strip()
if token:
return token
header_value = str(request.headers.get(self.AUTH_TOKEN_FALLBACK_HEADER) or "").strip()
if header_value:
return header_value
return str(request.query_params.get("auth_token") or "").strip()
@staticmethod
def is_public_api_path(path: str, method: str = "GET") -> bool:
raw = str(path or "").strip()
if not raw.startswith("/api/"):
return False
return raw in {
"/api/sys/auth/status",
"/api/sys/auth/login",
"/api/sys/auth/logout",
"/api/health",
"/api/health/cache",
}
def is_bot_enable_api_path(self, path: str, method: str = "GET") -> bool:
raw = str(path or "").strip()
verb = str(method or "GET").strip().upper()
if verb != "POST":
return False
bot_id = self.extract_bot_id_from_api_path(raw)
if not bot_id:
return False
return raw == f"/api/bots/{bot_id}/enable"
def validate_dashboard_auth(self, request: Request, session: Session) -> Optional[str]:
token = self.get_supplied_auth_token_http(request)
if not token:
return "Authentication required"
from services.sys_auth_service import resolve_user_by_token
user = resolve_user_by_token(session, token)
if user is None:
return "Session expired or invalid"
request.state.sys_auth_mode = "session_token"
request.state.sys_user_id = int(user.id or 0)
request.state.sys_username = str(user.username or "")
return None
@staticmethod
def _json_error(request: Request, *, status_code: int, detail: str) -> JSONResponse:
headers = {"Access-Control-Allow-Origin": "*"}
origin = str(request.headers.get("origin") or "").strip()
if origin:
headers["Vary"] = "Origin"
return JSONResponse(status_code=status_code, content={"detail": detail}, headers=headers)
async def guard(self, request: Request, call_next: Callable[..., Any]):
if request.method.upper() == "OPTIONS":
return await call_next(request)
if self.is_public_api_path(request.url.path, request.method):
return await call_next(request)
current_user_id = 0
with Session(self._engine) as session:
auth_error = self.validate_dashboard_auth(request, session)
if auth_error:
return self._json_error(request, status_code=401, detail=auth_error)
current_user_id = int(getattr(request.state, "sys_user_id", 0) or 0)
bot_id = self.extract_bot_id_from_api_path(request.url.path)
if not bot_id:
return await call_next(request)
with Session(self._engine) as session:
from models.sys_auth import SysUser
from services.sys_auth_service import user_can_access_bot
current_user = session.get(SysUser, current_user_id) if current_user_id > 0 else None
if current_user is None:
return self._json_error(request, status_code=401, detail="Authentication required")
if not user_can_access_bot(session, current_user, bot_id):
return self._json_error(request, status_code=403, detail="You do not have access to this bot")
bot = session.get(BotInstance, bot_id)
if not bot:
return self._json_error(request, status_code=404, detail="Bot not found")
enabled = bool(getattr(bot, "enabled", True))
if not enabled:
is_enable_api = self.is_bot_enable_api_path(request.url.path, request.method)
is_read_api = request.method.upper() == "GET"
if not (is_enable_api or is_read_api):
return self._json_error(request, status_code=403, detail="Bot is disabled. Enable it first.")
return await call_next(request)

View File

@ -0,0 +1,101 @@
from typing import Any, Callable, Dict, List
from fastapi import HTTPException
from sqlmodel import Session, select
from models.bot import BotInstance, NanobotImage
class ImageService:
def __init__(
self,
*,
cache: Any,
cache_key_images: Callable[[], str],
invalidate_images_cache: Callable[[], None],
reconcile_image_registry: Callable[[Session], None],
docker_manager: Any,
) -> None:
self._cache = cache
self._cache_key_images = cache_key_images
self._invalidate_images_cache = invalidate_images_cache
self._reconcile_image_registry = reconcile_image_registry
self._docker_manager = docker_manager
def list_images(self, *, session: Session) -> List[Dict[str, Any]]:
cached = self._cache.get_json(self._cache_key_images())
if isinstance(cached, list) and all(isinstance(row, dict) for row in cached):
return cached
if isinstance(cached, list):
self._invalidate_images_cache()
self._reconcile_image_registry(session)
rows = session.exec(select(NanobotImage)).all()
payload = [row.model_dump() for row in rows]
self._cache.set_json(self._cache_key_images(), payload, ttl=60)
return payload
def delete_image(self, *, session: Session, tag: str) -> Dict[str, Any]:
image = session.get(NanobotImage, tag)
if not image:
raise HTTPException(status_code=404, detail="Image not found")
bots_using = session.exec(select(BotInstance).where(BotInstance.image_tag == tag)).all()
if bots_using:
raise HTTPException(status_code=400, detail=f"Cannot delete image: {len(bots_using)} bots are using it.")
session.delete(image)
session.commit()
self._invalidate_images_cache()
return {"status": "deleted"}
def list_docker_images(self, *, repository: str = "nanobot-base") -> List[Dict[str, Any]]:
return self._docker_manager.list_images_by_repo(repository)
def register_image(self, *, session: Session, payload: Dict[str, Any]) -> NanobotImage:
tag = str(payload.get("tag") or "").strip()
source_dir = str(payload.get("source_dir") or "manual").strip() or "manual"
if not tag:
raise HTTPException(status_code=400, detail="tag is required")
if not self._docker_manager.has_image(tag):
raise HTTPException(status_code=404, detail=f"Docker image not found: {tag}")
version = tag.split(":")[-1].removeprefix("v") if ":" in tag else tag
try:
docker_img = self._docker_manager.client.images.get(tag) if self._docker_manager.client else None
image_id = docker_img.id if docker_img else None
except Exception:
image_id = None
row = session.get(NanobotImage, tag)
if not row:
row = NanobotImage(
tag=tag,
version=version,
status="READY",
source_dir=source_dir,
image_id=image_id,
)
else:
row.version = version
row.status = "READY"
row.source_dir = source_dir
row.image_id = image_id
session.add(row)
session.commit()
session.refresh(row)
self._invalidate_images_cache()
return row
def require_ready_image(self, session: Session, image_tag: str, *, require_local_image: bool) -> NanobotImage:
normalized_tag = str(image_tag or "").strip()
if not normalized_tag:
raise HTTPException(status_code=400, detail="image_tag is required")
image_row = session.get(NanobotImage, normalized_tag)
if not image_row:
raise HTTPException(status_code=400, detail=f"Image not registered in DB: {normalized_tag}")
if image_row.status != "READY":
raise HTTPException(status_code=400, detail=f"Image status is not READY: {normalized_tag} ({image_row.status})")
if require_local_image and not self._docker_manager.has_image(normalized_tag):
raise HTTPException(status_code=400, detail=f"Docker image not found locally: {normalized_tag}")
return image_row

View File

@ -0,0 +1,118 @@
import json
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 models.platform import BotActivityEvent
from schemas.platform import PlatformActivityItem, PlatformActivityListResponse
from services.platform_common import utcnow
from services.platform_settings_service import get_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",
}
_last_activity_event_prune_at: Optional[datetime] = None
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_activity_events(
session: Session,
bot_id: Optional[str] = None,
limit: int = 100,
offset: int = 0,
) -> Dict[str, Any]:
deleted = prune_expired_activity_events(session, force=False)
if deleted > 0:
session.commit()
safe_limit = max(1, min(int(limit), 500))
safe_offset = max(0, int(offset or 0))
stmt = (
select(BotActivityEvent)
.order_by(BotActivityEvent.created_at.desc(), BotActivityEvent.id.desc())
.offset(safe_offset)
.limit(safe_limit)
)
total_stmt = select(func.count(BotActivityEvent.id))
if bot_id:
stmt = stmt.where(BotActivityEvent.bot_id == bot_id)
total_stmt = total_stmt.where(BotActivityEvent.bot_id == bot_id)
rows = session.exec(stmt).all()
total = int(session.exec(total_stmt).one() or 0)
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 PlatformActivityListResponse(
items=[PlatformActivityItem.model_validate(item) for item in items],
total=total,
limit=safe_limit,
offset=safe_offset,
has_more=safe_offset + len(items) < total,
).model_dump()

View File

@ -0,0 +1,104 @@
from datetime import timedelta
from typing import Any, Dict
from sqlmodel import Session, select
from models.platform import BotRequestUsage
from schemas.platform import (
PlatformActivityItem,
PlatformDashboardAnalyticsResponse,
PlatformDashboardUsagePoint,
PlatformDashboardUsageSeries,
)
from services.platform_activity_service import list_activity_events
from services.platform_common import utcnow
from services.platform_settings_service import get_platform_settings
def build_dashboard_analytics(
session: Session,
*,
since_days: int = 7,
events_limit: int = 20,
) -> Dict[str, Any]:
safe_since_days = max(1, min(int(since_days or 7), 30))
safe_events_limit = max(1, min(int(events_limit or get_platform_settings(session).page_size), 100))
granularity = "hour" if safe_since_days <= 2 else "day"
now = utcnow()
if granularity == "hour":
current_bucket = now.replace(minute=0, second=0, microsecond=0)
bucket_starts = [current_bucket - timedelta(hours=index) for index in range(max(1, safe_since_days * 24))]
bucket_starts.reverse()
label_format = "%m-%d %H:00"
else:
current_bucket = now.replace(hour=0, minute=0, second=0, microsecond=0)
bucket_starts = [current_bucket - timedelta(days=index) for index in range(safe_since_days)]
bucket_starts.reverse()
label_format = "%m-%d"
bucket_index: Dict[str, int] = {}
points_template: list[PlatformDashboardUsagePoint] = []
for index, bucket_start in enumerate(bucket_starts):
bucket_key = bucket_start.isoformat()
bucket_index[bucket_key] = index
points_template.append(
PlatformDashboardUsagePoint(
bucket_at=bucket_start.isoformat() + "Z",
label=bucket_start.strftime(label_format),
call_count=0,
)
)
since = bucket_starts[0] if bucket_starts else now - timedelta(days=safe_since_days)
rows = session.exec(
select(BotRequestUsage)
.where(BotRequestUsage.started_at >= since)
.order_by(BotRequestUsage.started_at.asc(), BotRequestUsage.id.asc())
).all()
series_map: Dict[str, PlatformDashboardUsageSeries] = {}
total_request_count = 0
for row in rows:
total_request_count += 1
model_name = str(row.model or row.provider or "Unknown").strip() or "Unknown"
point_time = row.started_at or row.created_at or now
if granularity == "hour":
bucket_start = point_time.replace(minute=0, second=0, microsecond=0)
else:
bucket_start = point_time.replace(hour=0, minute=0, second=0, microsecond=0)
bucket_key = bucket_start.isoformat()
bucket_position = bucket_index.get(bucket_key)
if bucket_position is None:
continue
if model_name not in series_map:
series_map[model_name] = PlatformDashboardUsageSeries(
model=model_name,
total_calls=0,
points=[
PlatformDashboardUsagePoint.model_validate(point.model_dump())
for point in points_template
],
)
series = series_map[model_name]
series.total_calls += 1
series.points[bucket_position].call_count += 1
ordered_series = sorted(
series_map.values(),
key=lambda item: (-int(item.total_calls or 0), str(item.model or "").lower()),
)[:8]
return PlatformDashboardAnalyticsResponse(
total_request_count=total_request_count,
total_model_count=len(series_map),
granularity=granularity,
since_days=safe_since_days,
events_page_size=safe_events_limit,
series=ordered_series,
recent_events=[
PlatformActivityItem.model_validate(item)
for item in (list_activity_events(session, limit=safe_events_limit, offset=0).get("items") or [])
],
).model_dump()

View File

@ -0,0 +1,103 @@
import json
import math
import os
import re
from datetime import datetime
from typing import Any, Dict
from core.settings import BOTS_WORKSPACE_ROOT
def utcnow() -> datetime:
return datetime.utcnow()
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 file:
loaded = json.load(file)
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)

View File

@ -0,0 +1,236 @@
import logging
from typing import Any, Callable, Dict, List, Optional, Tuple
from sqlmodel import Session, select
from clients.edge.errors import log_edge_failure
from models.bot import BotInstance, NanobotImage
from services.platform_activity_service import list_activity_events, prune_expired_activity_events
from services.platform_common import read_bot_resources, workspace_usage_bytes
from services.platform_settings_service import get_platform_settings
from services.platform_usage_service import list_usage
logger = logging.getLogger(__name__)
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=get_platform_settings(session).page_size, offset=0).get("items") or []
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,
},
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,262 @@
import json
from typing import Any, Dict, List
from sqlmodel import Session, select
from core.database import engine
from models.platform import PlatformSetting
from schemas.platform import LoadingPageSettings, PlatformSettingsPayload, SystemSettingPayload
from services.platform_common import utcnow
from services.platform_settings_support import (
ACTIVITY_EVENT_RETENTION_SETTING_KEY,
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS,
DEPRECATED_SETTING_KEYS,
PROTECTED_SETTING_KEYS,
SETTING_KEYS,
SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY,
SYSTEM_SETTING_DEFINITIONS,
bootstrap_platform_setting_values,
build_speech_runtime_settings,
default_platform_settings,
normalize_activity_event_retention_days,
normalize_extension_list,
normalize_setting_key,
read_setting_value,
setting_item_from_row,
upsert_setting_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:
from services.platform_activity_service import prune_expired_activity_events
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]:
return build_speech_runtime_settings(get_platform_settings_snapshot())
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 get_sys_auth_token_ttl_days(session: Session) -> int:
ensure_default_system_settings(session)
row = session.get(PlatformSetting, SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY)
if row is None:
return 7
try:
value = int(read_setting_value(row))
except Exception:
value = 7
return max(1, min(365, value))

View File

@ -0,0 +1,352 @@
import json
import os
import re
from typing import Any, Dict, List
from sqlmodel import Session
from core.settings import (
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.platform import PlatformSetting
from schemas.platform import LoadingPageSettings, PlatformSettingsPayload, SystemSettingItem
from services.platform_common import utcnow
DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = ()
DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7
ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days"
SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY = "sys_auth_token_ttl_days"
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",
"dashboard_activity_page_size",
}
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,
},
SYS_AUTH_TOKEN_TTL_DAYS_SETTING_KEY: {
"name": "登录令牌有效天数",
"category": "auth",
"description": "用户登录 JWT 的失效天数,默认 7 天,同时作为 Redis 会话 TTL。",
"value_type": "integer",
"value": 7,
"is_public": False,
"sort_order": 36,
},
}
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 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 build_speech_runtime_settings(settings: PlatformSettingsPayload) -> Dict[str, Any]:
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,
}

View File

@ -0,0 +1,230 @@
import json
import uuid
from datetime import timedelta
from typing import Any, Dict, List, Optional
from sqlalchemy import func
from sqlmodel import Session, select
from models.platform import BotRequestUsage
from schemas.platform import PlatformUsageItem, PlatformUsageResponse, PlatformUsageSummary
from services.platform_common import estimate_tokens, utcnow
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 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()

View File

@ -0,0 +1,86 @@
from typing import Any, Callable, Dict, List, Optional, Tuple
import httpx
from fastapi import HTTPException
class ProviderTestService:
def __init__(self, *, provider_defaults: Optional[Callable[[str], Tuple[str, str]]] = None) -> None:
self._provider_defaults = provider_defaults or self.provider_defaults
@staticmethod
def provider_defaults(provider: str) -> tuple[str, str]:
normalized = provider.lower().strip()
if normalized in {"openrouter"}:
return "openrouter", "https://openrouter.ai/api/v1"
if normalized in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}:
return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1"
if normalized in {"xunfei", "iflytek", "xfyun"}:
return "openai", "https://spark-api-open.xf-yun.com/v1"
if normalized in {"kimi", "moonshot"}:
return "kimi", "https://api.moonshot.cn/v1"
if normalized in {"minimax"}:
return "minimax", "https://api.minimax.chat/v1"
if normalized in {"vllm"}:
return "vllm", ""
return normalized, ""
async def test_provider(self, *, payload: Dict[str, Any]) -> Dict[str, Any]:
provider = str(payload.get("provider") or "").strip()
api_key = str(payload.get("api_key") or "").strip()
model = str(payload.get("model") or "").strip()
api_base = str(payload.get("api_base") or "").strip()
if not provider or not api_key:
raise HTTPException(status_code=400, detail="provider and api_key are required")
normalized_provider, default_base = self._provider_defaults(provider)
base = (api_base or default_base).rstrip("/")
if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek", "vllm"}:
raise HTTPException(status_code=400, detail=f"provider not supported for test: {provider}")
if not base:
raise HTTPException(status_code=400, detail=f"api_base is required for provider: {provider}")
headers = {"Authorization": f"Bearer {api_key}"}
timeout = httpx.Timeout(20.0, connect=10.0)
url = f"{base}/models"
try:
async with httpx.AsyncClient(timeout=timeout) as client:
resp = await client.get(url, headers=headers)
if resp.status_code >= 400:
return {
"ok": False,
"provider": normalized_provider,
"status_code": resp.status_code,
"detail": resp.text[:500],
}
data = resp.json()
models_raw = data.get("data", []) if isinstance(data, dict) else []
model_ids: List[str] = []
for item in models_raw[:20]:
if isinstance(item, dict) and item.get("id"):
model_ids.append(str(item["id"]))
model_hint = ""
if model:
model_hint = "model_found" if any(model in value for value in model_ids) else "model_not_listed"
return {
"ok": True,
"provider": normalized_provider,
"endpoint": url,
"models_preview": model_ids[:8],
"model_hint": model_hint,
}
except Exception as exc:
return {
"ok": False,
"provider": normalized_provider,
"endpoint": url,
"detail": str(exc),
}

View File

@ -0,0 +1,289 @@
import asyncio
import json
import os
from datetime import datetime, timedelta, timezone
from typing import Any, Callable, Dict, List, Optional
from fastapi import HTTPException, WebSocket
from sqlmodel import Session
from models.bot import BotInstance, BotMessage
class WSConnectionManager:
def __init__(self) -> None:
self.connections: Dict[str, List[WebSocket]] = {}
async def connect(self, bot_id: str, websocket: WebSocket):
await websocket.accept()
self.connections.setdefault(bot_id, []).append(websocket)
def disconnect(self, bot_id: str, websocket: WebSocket):
conns = self.connections.get(bot_id, [])
if websocket in conns:
conns.remove(websocket)
if not conns and bot_id in self.connections:
del self.connections[bot_id]
async def broadcast(self, bot_id: str, data: Dict[str, Any]):
conns = list(self.connections.get(bot_id, []))
for ws in conns:
try:
await ws.send_json(data)
except Exception:
self.disconnect(bot_id, ws)
class RuntimeEventService:
def __init__(
self,
*,
app: Any,
engine: Any,
cache: Any,
logger: Any,
publish_runtime_topic_packet: Callable[..., None],
bind_usage_message: Callable[..., None],
finalize_usage_from_packet: Callable[..., Any],
workspace_root: Callable[[str], str],
parse_message_media: Callable[[str, Optional[str]], List[str]],
) -> None:
self._app = app
self._engine = engine
self._cache = cache
self._logger = logger
self._publish_runtime_topic_packet = publish_runtime_topic_packet
self._bind_usage_message = bind_usage_message
self._finalize_usage_from_packet = finalize_usage_from_packet
self._workspace_root = workspace_root
self._parse_message_media = parse_message_media
self.manager = WSConnectionManager()
@staticmethod
def cache_key_bots_list(user_id: Optional[int] = None) -> str:
normalized_user_id = int(user_id or 0)
return f"bots:list:user:{normalized_user_id}"
@staticmethod
def cache_key_bot_detail(bot_id: str) -> str:
return f"bot:detail:{bot_id}"
@staticmethod
def cache_key_bot_messages(bot_id: str, limit: int) -> str:
return f"bot:messages:v2:{bot_id}:limit:{limit}"
@staticmethod
def cache_key_bot_messages_page(bot_id: str, limit: int, before_id: Optional[int]) -> str:
cursor = str(int(before_id)) if isinstance(before_id, int) and before_id > 0 else "latest"
return f"bot:messages:page:v2:{bot_id}:before:{cursor}:limit:{limit}"
@staticmethod
def cache_key_images() -> str:
return "images:list"
def invalidate_bot_detail_cache(self, bot_id: str) -> None:
self._cache.delete(self.cache_key_bot_detail(bot_id))
self._cache.delete_prefix("bots:list:user:")
def invalidate_bot_messages_cache(self, bot_id: str) -> None:
self._cache.delete_prefix(f"bot:messages:{bot_id}:")
def invalidate_images_cache(self) -> None:
self._cache.delete(self.cache_key_images())
@staticmethod
def normalize_last_action_text(value: Any) -> str:
text = str(value or "").replace("\r\n", "\n").replace("\r", "\n").strip()
if not text:
return ""
text = __import__("re").sub(r"\n{4,}", "\n\n\n", text)
return text[:16000]
@staticmethod
def normalize_packet_channel(packet: Dict[str, Any]) -> str:
raw = str(packet.get("channel") or packet.get("source") or "").strip().lower()
if raw in {"dashboard", "dashboard_channel", "dashboard-channel"}:
return "dashboard"
return raw
def normalize_media_item(self, bot_id: str, value: Any) -> str:
raw = str(value or "").strip().replace("\\", "/")
if not raw:
return ""
if raw.startswith("/root/.nanobot/workspace/"):
return raw[len("/root/.nanobot/workspace/") :].lstrip("/")
root = self._workspace_root(bot_id)
if os.path.isabs(raw):
try:
if os.path.commonpath([root, raw]) == root:
return os.path.relpath(raw, root).replace("\\", "/")
except Exception:
pass
return raw.lstrip("/")
def normalize_media_list(self, raw: Any, bot_id: str) -> List[str]:
if not isinstance(raw, list):
return []
rows: List[str] = []
for value in raw:
normalized = self.normalize_media_item(bot_id, value)
if normalized:
rows.append(normalized)
return rows
def serialize_bot_message_row(self, bot_id: str, row: BotMessage) -> Dict[str, Any]:
created_at = row.created_at
if created_at.tzinfo is None:
created_at = created_at.replace(tzinfo=timezone.utc)
return {
"id": row.id,
"bot_id": row.bot_id,
"role": row.role,
"text": row.text,
"media": self._parse_message_media(bot_id, getattr(row, "media_json", None)),
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
"ts": int(created_at.timestamp() * 1000),
}
@staticmethod
def resolve_local_day_range(date_text: str, tz_offset_minutes: Optional[int]) -> tuple[datetime, datetime]:
try:
local_day = datetime.strptime(str(date_text or "").strip(), "%Y-%m-%d")
except ValueError as exc:
raise HTTPException(status_code=400, detail="Invalid date, expected YYYY-MM-DD") from exc
offset_minutes = 0
if tz_offset_minutes is not None:
try:
offset_minutes = int(tz_offset_minutes)
except (TypeError, ValueError) as exc:
raise HTTPException(status_code=400, detail="Invalid timezone offset") from exc
utc_start = local_day + timedelta(minutes=offset_minutes)
utc_end = utc_start + timedelta(days=1)
return utc_start, utc_end
def persist_runtime_packet(self, bot_id: str, packet: Dict[str, Any]) -> Optional[int]:
packet_type = str(packet.get("type", "")).upper()
if packet_type not in {"AGENT_STATE", "ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
return None
source_channel = self.normalize_packet_channel(packet)
if source_channel != "dashboard":
return None
persisted_message_id: Optional[int] = None
with Session(self._engine) as session:
bot = session.get(BotInstance, bot_id)
if not bot:
return None
if packet_type == "AGENT_STATE":
payload = packet.get("payload") or {}
state = str(payload.get("state") or "").strip()
action = self.normalize_last_action_text(payload.get("action_msg") or payload.get("msg") or "")
if state:
bot.current_state = state
if action:
bot.last_action = action
elif packet_type == "ASSISTANT_MESSAGE":
bot.current_state = "IDLE"
text_msg = str(packet.get("text") or "").strip()
media_list = self.normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
if text_msg:
bot.last_action = self.normalize_last_action_text(text_msg)
message_row = BotMessage(
bot_id=bot_id,
role="assistant",
text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
)
session.add(message_row)
session.flush()
persisted_message_id = message_row.id
self._finalize_usage_from_packet(
session,
bot_id,
{
**packet,
"message_id": persisted_message_id,
},
)
elif packet_type == "USER_COMMAND":
text_msg = str(packet.get("text") or "").strip()
media_list = self.normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
message_row = BotMessage(
bot_id=bot_id,
role="user",
text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
)
session.add(message_row)
session.flush()
persisted_message_id = message_row.id
self._bind_usage_message(
session,
bot_id,
str(packet.get("request_id") or "").strip(),
persisted_message_id,
)
elif packet_type == "BUS_EVENT":
is_progress = bool(packet.get("is_progress"))
detail_text = str(packet.get("content") or packet.get("text") or "").strip()
if not is_progress:
text_msg = detail_text
media_list = self.normalize_media_list(packet.get("media"), bot_id)
if text_msg or media_list:
bot.current_state = "IDLE"
if text_msg:
bot.last_action = self.normalize_last_action_text(text_msg)
message_row = BotMessage(
bot_id=bot_id,
role="assistant",
text=text_msg,
media_json=json.dumps(media_list, ensure_ascii=False) if media_list else None,
)
session.add(message_row)
session.flush()
persisted_message_id = message_row.id
self._finalize_usage_from_packet(
session,
bot_id,
{
"text": text_msg,
"usage": packet.get("usage"),
"request_id": packet.get("request_id"),
"provider": packet.get("provider"),
"model": packet.get("model"),
"message_id": persisted_message_id,
},
)
bot.updated_at = datetime.utcnow()
session.add(bot)
session.commit()
self._publish_runtime_topic_packet(
self._engine,
bot_id,
packet,
source_channel,
persisted_message_id,
self._logger,
)
if persisted_message_id:
packet["message_id"] = persisted_message_id
if packet_type in {"ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}:
self.invalidate_bot_messages_cache(bot_id)
self.invalidate_bot_detail_cache(bot_id)
return persisted_message_id
def broadcast_runtime_packet(self, bot_id: str, packet: Dict[str, Any], loop: Any) -> None:
asyncio.run_coroutine_threadsafe(self.manager.broadcast(bot_id, packet), loop)
def docker_callback(self, bot_id: str, packet: Dict[str, Any]):
self.persist_runtime_packet(bot_id, packet)
loop = getattr(self._app.state, "main_loop", None)
if not loop or not loop.is_running():
return
asyncio.run_coroutine_threadsafe(self.manager.broadcast(bot_id, packet), loop)

View File

@ -4,6 +4,7 @@ from typing import Any, Callable, Dict
from sqlmodel import Session, select
from models.bot import BotInstance, BotMessage
from fastapi import HTTPException
from providers.runtime.base import RuntimeProvider
from services.bot_command_service import BotCommandService
@ -28,6 +29,12 @@ class RuntimeService:
self._invalidate_bot_messages_cache = invalidate_bot_messages_cache
self._record_activity_event = record_activity_event
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
async def start_bot(self, *, app_state: Any, session: Session, bot: BotInstance) -> Dict[str, Any]:
result = await self._resolve_runtime_provider(app_state, bot).start_bot(session=session, bot=bot)
self._invalidate_bot_detail_cache(str(bot.id or ""))
@ -38,6 +45,35 @@ class RuntimeService:
self._invalidate_bot_detail_cache(str(bot.id or ""))
return result
async def clear_messages_for_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.clear_messages(app_state=app_state, session=session, bot=bot)
def clear_dashboard_direct_session_for_bot(self, *, app_state: Any, session: Session, bot_id: str) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.clear_dashboard_direct_session(app_state=app_state, session=session, bot=bot)
def get_logs_for_bot(self, *, app_state: Any, session: Session, bot_id: str, tail: int = 300) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.get_logs(app_state=app_state, bot=bot, tail=tail)
def send_command_for_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
payload: Any,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.send_command(
app_state=app_state,
session=session,
bot_id=bot_id,
bot=bot,
payload=payload,
)
def send_command(
self,
*,

View File

@ -0,0 +1,898 @@
import json
import logging
import os
import re
import shutil
import tempfile
import zipfile
from datetime import datetime
from typing import Any, Callable, Dict, List, Optional
from fastapi import HTTPException, UploadFile
from sqlmodel import Session, select
from clients.edge.errors import log_edge_failure
from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT
from models.bot import BotInstance
from models.skill import BotSkillInstall, SkillMarketItem
from services.platform_settings_service import get_platform_settings_snapshot
EdgeStateContextResolver = Callable[[str], Optional[tuple[Any, Optional[str], str]]]
class SkillService:
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def _workspace_root(self, bot_id: str) -> str:
return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace"))
def _skills_root(self, bot_id: str) -> str:
return os.path.join(self._workspace_root(bot_id), "skills")
def _skill_market_root(self) -> str:
return os.path.abspath(os.path.join(DATA_ROOT, "skills"))
def _is_valid_top_level_skill_name(self, name: str) -> bool:
text = str(name or "").strip()
if not text:
return False
if "/" in text or "\\" in text:
return False
if text in {".", ".."}:
return False
return True
def _read_skill_description(self, entry_path: str) -> str:
candidates: List[str] = []
if os.path.isdir(entry_path):
candidates = [
os.path.join(entry_path, "SKILL.md"),
os.path.join(entry_path, "skill.md"),
os.path.join(entry_path, "README.md"),
os.path.join(entry_path, "readme.md"),
]
elif entry_path.lower().endswith(".md"):
candidates = [entry_path]
for candidate in candidates:
if not os.path.isfile(candidate):
continue
try:
with open(candidate, "r", encoding="utf-8") as file:
for line in file:
text = line.strip()
if text and not text.startswith("#"):
return text[:240]
except Exception:
continue
return ""
def list_workspace_skills(
self,
*,
bot_id: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
edge_context = resolve_edge_state_context(bot_id)
if edge_context is not None:
client, workspace_root, node_id = edge_context
try:
payload = client.list_tree(
bot_id=bot_id,
path="skills",
recursive=False,
workspace_root=workspace_root,
)
except Exception as exc:
log_edge_failure(
logger,
key=f"skills-list:{node_id}:{bot_id}",
exc=exc,
message=f"Failed to list skills from edge workspace for bot_id={bot_id}",
)
return []
rows: List[Dict[str, Any]] = []
for entry in list(payload.get("entries") or []):
if not isinstance(entry, dict):
continue
name = str(entry.get("name") or "").strip()
if not name or name.startswith("."):
continue
if not self._is_valid_top_level_skill_name(name):
continue
entry_type = str(entry.get("type") or "").strip().lower()
if entry_type not in {"dir", "file"}:
continue
mtime = str(entry.get("mtime") or "").strip() or (datetime.utcnow().isoformat() + "Z")
size = entry.get("size")
rows.append(
{
"id": name,
"name": name,
"type": entry_type,
"path": f"skills/{name}",
"size": int(size) if isinstance(size, (int, float)) and entry_type == "file" else None,
"mtime": mtime,
"description": "",
}
)
rows.sort(key=lambda row: (row.get("type") != "dir", str(row.get("name") or "").lower()))
return rows
root = self._skills_root(bot_id)
if not os.path.isdir(root):
return []
rows: List[Dict[str, Any]] = []
names = sorted(os.listdir(root), key=lambda name: (not os.path.isdir(os.path.join(root, name)), name.lower()))
for name in names:
if not name or name.startswith("."):
continue
if not self._is_valid_top_level_skill_name(name):
continue
abs_path = os.path.join(root, name)
if not os.path.exists(abs_path):
continue
stat = os.stat(abs_path)
rows.append(
{
"id": name,
"name": name,
"type": "dir" if os.path.isdir(abs_path) else "file",
"path": f"skills/{name}",
"size": stat.st_size if os.path.isfile(abs_path) else None,
"mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z",
"description": self._read_skill_description(abs_path),
}
)
return rows
def list_workspace_skills_for_bot(
self,
*,
session: Session,
bot_id: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
self._require_bot(session=session, bot_id=bot_id)
return self.list_workspace_skills(
bot_id=bot_id,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
def _parse_json_string_list(self, raw: Any) -> List[str]:
if not raw:
return []
try:
data = json.loads(str(raw))
except Exception:
return []
if not isinstance(data, list):
return []
rows: List[str] = []
for item in data:
text = str(item or "").strip()
if text and text not in rows:
rows.append(text)
return rows
def _is_ignored_skill_zip_top_level(self, name: str) -> bool:
text = str(name or "").strip()
if not text:
return True
lowered = text.lower()
if lowered == "__macosx":
return True
if text.startswith("."):
return True
return False
def _read_description_from_text(self, raw: str) -> str:
for line in str(raw or "").splitlines():
text = line.strip()
if text and not text.startswith("#"):
return text[:240]
return ""
def _extract_skill_zip_summary(self, zip_path: str) -> Dict[str, Any]:
entry_names: List[str] = []
description = ""
with zipfile.ZipFile(zip_path) as archive:
members = archive.infolist()
file_members = [member for member in members if not member.is_dir()]
for member in file_members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
first = raw_name.split("/", 1)[0].strip()
if self._is_ignored_skill_zip_top_level(first):
continue
if self._is_valid_top_level_skill_name(first) and first not in entry_names:
entry_names.append(first)
candidates = sorted(
[
str(member.filename or "").replace("\\", "/").lstrip("/")
for member in file_members
if str(member.filename or "").replace("\\", "/").rsplit("/", 1)[-1].lower() in {"skill.md", "readme.md"}
],
key=lambda value: (value.count("/"), value.lower()),
)
for candidate in candidates:
try:
with archive.open(candidate, "r") as file:
preview = file.read(4096).decode("utf-8", errors="ignore")
description = self._read_description_from_text(preview)
if description:
break
except Exception:
continue
return {
"entry_names": entry_names,
"description": description,
}
def _sanitize_skill_market_key(self, raw: Any) -> str:
value = str(raw or "").strip().lower()
value = re.sub(r"[^a-z0-9._-]+", "-", value)
value = re.sub(r"-{2,}", "-", value).strip("._-")
return value[:120]
def _sanitize_zip_filename(self, raw: Any) -> str:
filename = os.path.basename(str(raw or "").strip())
if not filename:
return ""
filename = filename.replace("\\", "/").rsplit("/", 1)[-1]
stem, ext = os.path.splitext(filename)
safe_stem = re.sub(r"[^A-Za-z0-9._-]+", "-", stem).strip("._-")
if not safe_stem:
safe_stem = "skill-package"
safe_ext = ".zip" if ext.lower() == ".zip" else ""
return f"{safe_stem[:180]}{safe_ext}"
def _resolve_unique_skill_market_key(self, session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str:
base_key = self._sanitize_skill_market_key(preferred_key) or "skill"
candidate = base_key
counter = 2
while True:
stmt = select(SkillMarketItem).where(SkillMarketItem.skill_key == candidate)
rows = session.exec(stmt).all()
conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
if not conflict:
return candidate
candidate = f"{base_key}-{counter}"
counter += 1
def _resolve_unique_skill_market_zip_filename(
self,
session: Session,
filename: str,
*,
exclude_filename: Optional[str] = None,
exclude_id: Optional[int] = None,
) -> str:
root = self._skill_market_root()
os.makedirs(root, exist_ok=True)
safe_name = self._sanitize_zip_filename(filename)
if not safe_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
candidate = safe_name
stem, ext = os.path.splitext(safe_name)
counter = 2
while True:
file_conflict = os.path.exists(os.path.join(root, candidate)) and candidate != str(exclude_filename or "").strip()
rows = session.exec(select(SkillMarketItem).where(SkillMarketItem.zip_filename == candidate)).all()
db_conflict = next((row for row in rows if exclude_id is None or row.id != exclude_id), None)
if not file_conflict and not db_conflict:
return candidate
candidate = f"{stem}-{counter}{ext}"
counter += 1
async def _store_skill_market_zip_upload(
self,
session: Session,
upload: UploadFile,
*,
exclude_filename: Optional[str] = None,
exclude_id: Optional[int] = None,
) -> Dict[str, Any]:
root = self._skill_market_root()
os.makedirs(root, exist_ok=True)
incoming_name = self._sanitize_zip_filename(upload.filename or "")
if not incoming_name.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
target_filename = self._resolve_unique_skill_market_zip_filename(
session,
incoming_name,
exclude_filename=exclude_filename,
exclude_id=exclude_id,
)
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
total_size = 0
tmp_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(prefix=".skill_market_", suffix=".zip", dir=root, delete=False) as tmp_zip:
tmp_path = tmp_zip.name
while True:
chunk = await upload.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
)
tmp_zip.write(chunk)
if total_size == 0:
raise HTTPException(status_code=400, detail="Zip package is empty")
summary = self._extract_skill_zip_summary(tmp_path)
if not summary["entry_names"]:
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
final_path = os.path.join(root, target_filename)
os.replace(tmp_path, final_path)
tmp_path = None
return {
"zip_filename": target_filename,
"zip_size_bytes": total_size,
"entry_names": summary["entry_names"],
"description": summary["description"],
}
except zipfile.BadZipFile as exc:
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
finally:
await upload.close()
if tmp_path and os.path.exists(tmp_path):
os.remove(tmp_path)
def serialize_skill_market_item(
self,
item: SkillMarketItem,
*,
install_count: int = 0,
install_row: Optional[BotSkillInstall] = None,
workspace_installed: Optional[bool] = None,
installed_entries: Optional[List[str]] = None,
) -> Dict[str, Any]:
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
entry_names = self._parse_json_string_list(item.entry_names_json)
payload = {
"id": item.id,
"skill_key": item.skill_key,
"display_name": item.display_name or item.skill_key,
"description": item.description or "",
"zip_filename": item.zip_filename,
"zip_size_bytes": int(item.zip_size_bytes or 0),
"entry_names": entry_names,
"entry_count": len(entry_names),
"zip_exists": os.path.isfile(zip_path),
"install_count": int(install_count or 0),
"created_at": item.created_at.isoformat() + "Z" if item.created_at else None,
"updated_at": item.updated_at.isoformat() + "Z" if item.updated_at else None,
}
if install_row is not None:
resolved_entries = installed_entries if installed_entries is not None else self._parse_json_string_list(install_row.installed_entries_json)
resolved_installed = workspace_installed if workspace_installed is not None else install_row.status == "INSTALLED"
payload.update(
{
"installed": resolved_installed,
"install_status": install_row.status,
"installed_at": install_row.installed_at.isoformat() + "Z" if install_row.installed_at else None,
"installed_entries": resolved_entries,
"install_error": install_row.last_error,
}
)
return payload
def list_market_items(self, *, session: Session) -> List[Dict[str, Any]]:
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
installs = session.exec(select(BotSkillInstall)).all()
install_count_by_skill: Dict[int, int] = {}
for row in installs:
skill_id = int(row.skill_market_item_id or 0)
if skill_id <= 0 or row.status != "INSTALLED":
continue
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
return [
self.serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0))
for item in items
]
async def create_market_item(
self,
*,
session: Session,
skill_key: str,
display_name: str,
description: str,
file: UploadFile,
) -> Dict[str, Any]:
upload_meta = await self._store_skill_market_zip_upload(session, file)
try:
preferred_key = skill_key or display_name or os.path.splitext(upload_meta["zip_filename"])[0]
next_key = self._resolve_unique_skill_market_key(session, preferred_key)
item = SkillMarketItem(
skill_key=next_key,
display_name=str(display_name or next_key).strip() or next_key,
description=str(description or upload_meta["description"] or "").strip(),
zip_filename=upload_meta["zip_filename"],
zip_size_bytes=int(upload_meta["zip_size_bytes"] or 0),
entry_names_json=json.dumps(upload_meta["entry_names"], ensure_ascii=False),
)
session.add(item)
session.commit()
session.refresh(item)
return self.serialize_skill_market_item(item, install_count=0)
except Exception:
target_path = os.path.join(self._skill_market_root(), upload_meta["zip_filename"])
if os.path.exists(target_path):
os.remove(target_path)
raise
async def update_market_item(
self,
*,
session: Session,
skill_id: int,
skill_key: str,
display_name: str,
description: str,
file: Optional[UploadFile],
) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
old_filename = str(item.zip_filename or "").strip()
upload_meta: Optional[Dict[str, Any]] = None
if file is not None:
upload_meta = await self._store_skill_market_zip_upload(
session,
file,
exclude_filename=old_filename or None,
exclude_id=item.id,
)
next_key = self._resolve_unique_skill_market_key(
session,
skill_key or item.skill_key or display_name or os.path.splitext(upload_meta["zip_filename"] if upload_meta else old_filename)[0],
exclude_id=item.id,
)
item.skill_key = next_key
item.display_name = str(display_name or item.display_name or next_key).strip() or next_key
item.description = str(description or (upload_meta["description"] if upload_meta else item.description) or "").strip()
item.updated_at = datetime.utcnow()
if upload_meta:
item.zip_filename = upload_meta["zip_filename"]
item.zip_size_bytes = int(upload_meta["zip_size_bytes"] or 0)
item.entry_names_json = json.dumps(upload_meta["entry_names"], ensure_ascii=False)
session.add(item)
session.commit()
session.refresh(item)
if upload_meta and old_filename and old_filename != upload_meta["zip_filename"]:
old_path = os.path.join(self._skill_market_root(), old_filename)
if os.path.exists(old_path):
os.remove(old_path)
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
install_count = sum(1 for row in installs if row.status == "INSTALLED")
return self.serialize_skill_market_item(item, install_count=install_count)
def delete_market_item(self, *, session: Session, skill_id: int) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
zip_filename = str(item.zip_filename or "").strip()
installs = session.exec(select(BotSkillInstall).where(BotSkillInstall.skill_market_item_id == skill_id)).all()
for row in installs:
session.delete(row)
session.delete(item)
session.commit()
if zip_filename:
zip_path = os.path.join(self._skill_market_root(), zip_filename)
if os.path.exists(zip_path):
os.remove(zip_path)
return {"status": "deleted", "id": skill_id}
def list_bot_market_items(
self,
*,
bot_id: str,
session: Session,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
items = session.exec(select(SkillMarketItem).order_by(SkillMarketItem.display_name, SkillMarketItem.id)).all()
install_rows = session.exec(select(BotSkillInstall).where(BotSkillInstall.bot_id == bot_id)).all()
install_lookup = {int(row.skill_market_item_id): row for row in install_rows}
all_install_rows = session.exec(select(BotSkillInstall)).all()
install_count_by_skill: Dict[int, int] = {}
for row in all_install_rows:
skill_id = int(row.skill_market_item_id or 0)
if skill_id <= 0 or row.status != "INSTALLED":
continue
install_count_by_skill[skill_id] = install_count_by_skill.get(skill_id, 0) + 1
workspace_skill_names = {
str(row.get("name") or "").strip()
for row in self.list_workspace_skills(bot_id=bot_id, resolve_edge_state_context=resolve_edge_state_context, logger=logger)
}
return [
self.serialize_skill_market_item(
item,
install_count=install_count_by_skill.get(int(item.id or 0), 0),
install_row=install_lookup.get(int(item.id or 0)),
workspace_installed=(
None
if install_lookup.get(int(item.id or 0)) is None
else (
install_lookup[int(item.id or 0)].status == "INSTALLED"
and all(
name in workspace_skill_names
for name in self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
)
)
),
installed_entries=(
None
if install_lookup.get(int(item.id or 0)) is None
else self._parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json)
),
)
for item in items
]
def list_bot_market_items_for_bot(
self,
*,
session: Session,
bot_id: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> List[Dict[str, Any]]:
self._require_bot(session=session, bot_id=bot_id)
return self.list_bot_market_items(
bot_id=bot_id,
session=session,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
def _install_skill_zip_into_workspace(
self,
*,
bot_id: str,
zip_path: str,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
try:
archive = zipfile.ZipFile(zip_path)
except Exception as exc:
raise HTTPException(status_code=400, detail="Invalid zip file") from exc
edge_context = resolve_edge_state_context(bot_id)
skills_root = self._skills_root(bot_id)
installed: List[str] = []
with archive:
members = archive.infolist()
file_members = [member for member in members if not member.is_dir()]
if not file_members:
raise HTTPException(status_code=400, detail="Zip package has no files")
top_names: List[str] = []
for member in file_members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
first = raw_name.split("/", 1)[0].strip()
if self._is_ignored_skill_zip_top_level(first):
continue
if not self._is_valid_top_level_skill_name(first):
raise HTTPException(status_code=400, detail=f"Invalid skill entry name in zip: {first}")
if first not in top_names:
top_names.append(first)
if not top_names:
raise HTTPException(status_code=400, detail="Zip package has no valid skill entries")
if edge_context is not None:
existing_names = {
str(item.get("name") or "").strip()
for item in self.list_workspace_skills(
bot_id=bot_id,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
if isinstance(item, dict)
}
conflicts = [name for name in top_names if name in existing_names]
else:
os.makedirs(skills_root, exist_ok=True)
conflicts = [name for name in top_names if os.path.exists(os.path.join(skills_root, name))]
if conflicts:
raise HTTPException(status_code=400, detail=f"Skill already exists: {', '.join(conflicts)}")
temp_dir_root = skills_root if edge_context is None else None
with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=temp_dir_root) as tmp_dir:
tmp_root = os.path.abspath(tmp_dir)
for member in members:
raw_name = str(member.filename or "").replace("\\", "/").lstrip("/")
if not raw_name:
continue
target = os.path.abspath(os.path.join(tmp_root, raw_name))
if os.path.commonpath([tmp_root, target]) != tmp_root:
raise HTTPException(status_code=400, detail=f"Unsafe zip entry path: {raw_name}")
if member.is_dir():
os.makedirs(target, exist_ok=True)
continue
os.makedirs(os.path.dirname(target), exist_ok=True)
with archive.open(member, "r") as source, open(target, "wb") as dest:
shutil.copyfileobj(source, dest)
if edge_context is not None:
client, workspace_root, _node_id = edge_context
upload_groups: Dict[str, List[str]] = {}
for name in top_names:
src = os.path.join(tmp_root, name)
if not os.path.exists(src):
continue
if os.path.isfile(src):
upload_groups.setdefault("skills", []).append(src)
installed.append(name)
continue
for walk_root, _dirs, files in os.walk(src):
for filename in files:
local_path = os.path.join(walk_root, filename)
relative_path = os.path.relpath(local_path, tmp_root).replace("\\", "/")
relative_dir = os.path.dirname(relative_path).strip("/")
target_dir = f"skills/{relative_dir}" if relative_dir else "skills"
upload_groups.setdefault(target_dir, []).append(local_path)
installed.append(name)
for target_dir, local_paths in upload_groups.items():
client.upload_local_files(
bot_id=bot_id,
local_paths=local_paths,
path=target_dir,
workspace_root=workspace_root,
)
else:
for name in top_names:
src = os.path.join(tmp_root, name)
dst = os.path.join(skills_root, name)
if not os.path.exists(src):
continue
shutil.move(src, dst)
installed.append(name)
if not installed:
raise HTTPException(status_code=400, detail="No skill entries installed from zip")
return {
"installed": installed,
"skills": self.list_workspace_skills(
bot_id=bot_id,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
),
}
def install_market_item_for_bot(
self,
*,
bot_id: str,
skill_id: int,
session: Session,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
item = session.get(SkillMarketItem, skill_id)
if not item:
raise HTTPException(status_code=404, detail="Skill market item not found")
zip_path = os.path.join(self._skill_market_root(), str(item.zip_filename or ""))
if not os.path.isfile(zip_path):
raise HTTPException(status_code=404, detail="Skill zip package not found")
install_row = session.exec(
select(BotSkillInstall).where(
BotSkillInstall.bot_id == bot_id,
BotSkillInstall.skill_market_item_id == skill_id,
)
).first()
try:
install_result = self._install_skill_zip_into_workspace(
bot_id=bot_id,
zip_path=zip_path,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
)
install_row.installed_entries_json = json.dumps(install_result["installed"], ensure_ascii=False)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "INSTALLED"
install_row.last_error = None
install_row.installed_at = now
install_row.updated_at = now
session.add(install_row)
session.commit()
session.refresh(install_row)
return {
"status": "installed",
"bot_id": bot_id,
"skill_market_item_id": skill_id,
"installed": install_result["installed"],
"skills": install_result["skills"],
"market_item": self.serialize_skill_market_item(item, install_count=0, install_row=install_row),
}
except HTTPException as exc:
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
installed_at=now,
)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "FAILED"
install_row.last_error = str(exc.detail or "Install failed")
install_row.updated_at = now
session.add(install_row)
session.commit()
raise
except Exception as exc:
now = datetime.utcnow()
if not install_row:
install_row = BotSkillInstall(
bot_id=bot_id,
skill_market_item_id=skill_id,
installed_at=now,
)
install_row.source_zip_filename = str(item.zip_filename or "")
install_row.status = "FAILED"
install_row.last_error = str(exc or "Install failed")[:1000]
install_row.updated_at = now
session.add(install_row)
session.commit()
raise HTTPException(status_code=500, detail="Skill install failed unexpectedly") from exc
def install_market_item_for_bot_checked(
self,
*,
session: Session,
bot_id: str,
skill_id: int,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.install_market_item_for_bot(
bot_id=bot_id,
skill_id=skill_id,
session=session,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
async def upload_bot_skill_zip(
self,
*,
bot_id: str,
file: UploadFile,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
tmp_zip_path: Optional[str] = None
try:
with tempfile.NamedTemporaryFile(prefix=".skill_upload_", suffix=".zip", delete=False) as tmp_zip:
tmp_zip_path = tmp_zip.name
filename = str(file.filename or "").strip()
if not filename.lower().endswith(".zip"):
raise HTTPException(status_code=400, detail="Only .zip skill package is supported")
max_bytes = get_platform_settings_snapshot().upload_max_mb * 1024 * 1024
total_size = 0
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
total_size += len(chunk)
if total_size > max_bytes:
raise HTTPException(
status_code=413,
detail=f"Zip package too large (max {max_bytes // (1024 * 1024)}MB)",
)
tmp_zip.write(chunk)
if total_size == 0:
raise HTTPException(status_code=400, detail="Zip package is empty")
finally:
await file.close()
try:
install_result = self._install_skill_zip_into_workspace(
bot_id=bot_id,
zip_path=tmp_zip_path,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
finally:
if tmp_zip_path and os.path.exists(tmp_zip_path):
os.remove(tmp_zip_path)
return {
"status": "installed",
"bot_id": bot_id,
"installed": install_result["installed"],
"skills": install_result["skills"],
}
async def upload_bot_skill_zip_for_bot(
self,
*,
session: Session,
bot_id: str,
file: UploadFile,
resolve_edge_state_context: EdgeStateContextResolver,
logger: logging.Logger,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return await self.upload_bot_skill_zip(
bot_id=bot_id,
file=file,
resolve_edge_state_context=resolve_edge_state_context,
logger=logger,
)
def delete_workspace_skill(
self,
*,
bot_id: str,
skill_name: str,
resolve_edge_state_context: EdgeStateContextResolver,
) -> Dict[str, Any]:
if resolve_edge_state_context(bot_id) is not None:
raise HTTPException(
status_code=400,
detail="Edge bot skill delete is disabled here. Use edge workspace file management.",
)
name = str(skill_name or "").strip()
if not self._is_valid_top_level_skill_name(name):
raise HTTPException(status_code=400, detail="Invalid skill name")
root = self._skills_root(bot_id)
target = os.path.abspath(os.path.join(root, name))
if os.path.commonpath([os.path.abspath(root), target]) != os.path.abspath(root):
raise HTTPException(status_code=400, detail="Invalid skill path")
if not os.path.exists(target):
raise HTTPException(status_code=404, detail="Skill not found in workspace")
if os.path.isdir(target):
shutil.rmtree(target, ignore_errors=False)
else:
os.remove(target)
return {"status": "deleted", "bot_id": bot_id, "skill": name}
def delete_workspace_skill_for_bot(
self,
*,
session: Session,
bot_id: str,
skill_name: str,
resolve_edge_state_context: EdgeStateContextResolver,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
return self.delete_workspace_skill(
bot_id=bot_id,
skill_name=skill_name,
resolve_edge_state_context=resolve_edge_state_context,
)

View File

@ -0,0 +1,126 @@
import asyncio
import os
import tempfile
from typing import Any, Callable, Dict, Optional
from fastapi import HTTPException, UploadFile
from sqlmodel import Session
from core.speech_service import SpeechDisabledError, SpeechDurationError, SpeechServiceError
from models.bot import BotInstance
class SpeechTranscriptionService:
def __init__(
self,
*,
data_root: str,
speech_service: Any,
get_speech_runtime_settings: Callable[[], Dict[str, Any]],
logger: Any,
) -> None:
self._data_root = data_root
self._speech_service = speech_service
self._get_speech_runtime_settings = get_speech_runtime_settings
self._logger = logger
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
async def transcribe(
self,
*,
session: Session,
bot_id: str,
file: UploadFile,
language: Optional[str] = None,
) -> Dict[str, Any]:
self._require_bot(session=session, bot_id=bot_id)
speech_settings = self._get_speech_runtime_settings()
if not speech_settings["enabled"]:
raise HTTPException(status_code=400, detail="Speech recognition is disabled")
if not file:
raise HTTPException(status_code=400, detail="no audio file uploaded")
original_name = str(file.filename or "audio.webm").strip() or "audio.webm"
safe_name = os.path.basename(original_name).replace("\\", "_").replace("/", "_")
ext = os.path.splitext(safe_name)[1].strip().lower() or ".webm"
if len(ext) > 12:
ext = ".webm"
tmp_path = ""
try:
with tempfile.NamedTemporaryFile(delete=False, suffix=ext, prefix=".speech_", dir=self._data_root) as tmp:
tmp_path = tmp.name
while True:
chunk = await file.read(1024 * 1024)
if not chunk:
break
tmp.write(chunk)
if not tmp_path or not os.path.exists(tmp_path) or os.path.getsize(tmp_path) <= 0:
raise HTTPException(status_code=400, detail="audio payload is empty")
resolved_language = str(language or "").strip() or speech_settings["default_language"]
result = await asyncio.to_thread(self._speech_service.transcribe_file, tmp_path, resolved_language)
text = str(result.get("text") or "").strip()
if not text:
raise HTTPException(status_code=400, detail="No speech detected")
return {
"bot_id": bot_id,
"text": text,
"duration_seconds": result.get("duration_seconds"),
"max_audio_seconds": speech_settings["max_audio_seconds"],
"model": speech_settings["model"],
"device": speech_settings["device"],
"language": result.get("language") or resolved_language,
}
except SpeechDisabledError as exc:
self._logger.warning(
"speech transcribe disabled bot_id=%s file=%s language=%s detail=%s",
bot_id,
safe_name,
language,
exc,
)
raise HTTPException(status_code=400, detail=str(exc))
except SpeechDurationError:
self._logger.warning(
"speech transcribe too long bot_id=%s file=%s language=%s max_seconds=%s",
bot_id,
safe_name,
language,
speech_settings["max_audio_seconds"],
)
raise HTTPException(status_code=413, detail=f"Audio duration exceeds {speech_settings['max_audio_seconds']} seconds")
except SpeechServiceError as exc:
self._logger.exception(
"speech transcribe failed bot_id=%s file=%s language=%s",
bot_id,
safe_name,
language,
)
raise HTTPException(status_code=400, detail=str(exc))
except HTTPException:
raise
except Exception as exc:
self._logger.exception(
"speech transcribe unexpected error bot_id=%s file=%s language=%s",
bot_id,
safe_name,
language,
)
raise HTTPException(status_code=500, detail=f"speech transcription failed: {exc}")
finally:
try:
await file.close()
except Exception:
pass
if tmp_path and os.path.exists(tmp_path):
try:
os.remove(tmp_path)
except Exception:
pass

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,155 @@
import json
import os
from typing import Any, Callable, Dict
from fastapi import HTTPException
from sqlmodel import Session, select
from models.bot import BotInstance
class SystemService:
def __init__(
self,
*,
engine: Any,
cache: Any,
database_engine: str,
redis_enabled: bool,
redis_url: str,
redis_prefix: str,
agent_md_templates_file: str,
topic_presets_templates_file: str,
default_soul_md: str,
default_agents_md: str,
default_user_md: str,
default_tools_md: str,
default_identity_md: str,
topic_preset_templates: Any,
get_default_system_timezone: Callable[[], str],
load_agent_md_templates: Callable[[], Dict[str, Any]],
load_topic_presets_template: Callable[[], Dict[str, Any]],
get_platform_settings_snapshot: Callable[[], Any],
get_speech_runtime_settings: Callable[[], Dict[str, Any]],
) -> None:
self._engine = engine
self._cache = cache
self._database_engine = database_engine
self._redis_enabled = redis_enabled
self._redis_url = redis_url
self._redis_prefix = redis_prefix
self._agent_md_templates_file = agent_md_templates_file
self._topic_presets_templates_file = topic_presets_templates_file
self._default_soul_md = default_soul_md
self._default_agents_md = default_agents_md
self._default_user_md = default_user_md
self._default_tools_md = default_tools_md
self._default_identity_md = default_identity_md
self._topic_preset_templates = topic_preset_templates
self._get_default_system_timezone = get_default_system_timezone
self._load_agent_md_templates = load_agent_md_templates
self._load_topic_presets_template = load_topic_presets_template
self._get_platform_settings_snapshot = get_platform_settings_snapshot
self._get_speech_runtime_settings = get_speech_runtime_settings
@staticmethod
def _write_json_atomic(path: str, payload: Dict[str, Any]) -> None:
os.makedirs(os.path.dirname(path), exist_ok=True)
tmp = f"{path}.tmp"
with open(tmp, "w", encoding="utf-8") as file:
json.dump(payload, file, ensure_ascii=False, indent=2)
os.replace(tmp, path)
def get_system_defaults(self) -> Dict[str, Any]:
md_templates = self._load_agent_md_templates()
topic_presets = self._load_topic_presets_template()
platform_settings = self._get_platform_settings_snapshot()
speech_settings = self._get_speech_runtime_settings()
return {
"templates": {
"soul_md": md_templates.get("soul_md") or self._default_soul_md,
"agents_md": md_templates.get("agents_md") or self._default_agents_md,
"user_md": md_templates.get("user_md") or self._default_user_md,
"tools_md": md_templates.get("tools_md") or self._default_tools_md,
"identity_md": md_templates.get("identity_md") or self._default_identity_md,
},
"limits": {
"upload_max_mb": platform_settings.upload_max_mb,
},
"workspace": {
"download_extensions": list(platform_settings.workspace_download_extensions),
"allowed_attachment_extensions": list(platform_settings.allowed_attachment_extensions),
},
"bot": {
"system_timezone": self._get_default_system_timezone(),
},
"loading_page": platform_settings.loading_page.model_dump(),
"chat": {
"pull_page_size": platform_settings.chat_pull_page_size,
"page_size": platform_settings.page_size,
"command_auto_unlock_seconds": platform_settings.command_auto_unlock_seconds,
},
"topic_presets": topic_presets.get("presets") or self._topic_preset_templates,
"speech": {
"enabled": speech_settings["enabled"],
"model": speech_settings["model"],
"device": speech_settings["device"],
"max_audio_seconds": speech_settings["max_audio_seconds"],
"default_language": speech_settings["default_language"],
},
}
def get_system_templates(self) -> Dict[str, Any]:
return {
"agent_md_templates": self._load_agent_md_templates(),
"topic_presets": self._load_topic_presets_template(),
}
def update_system_templates(self, *, payload: Any) -> Dict[str, Any]:
if payload.agent_md_templates is not None:
sanitized_agent: Dict[str, str] = {}
for key in ("agents_md", "soul_md", "user_md", "tools_md", "identity_md"):
sanitized_agent[key] = str(payload.agent_md_templates.get(key, "") or "").replace("\r\n", "\n")
self._write_json_atomic(str(self._agent_md_templates_file), sanitized_agent)
if payload.topic_presets is not None:
presets = payload.topic_presets.get("presets") if isinstance(payload.topic_presets, dict) else None
if presets is None:
normalized_topic: Dict[str, Any] = {"presets": []}
elif isinstance(presets, list):
normalized_topic = {"presets": [dict(row) for row in presets if isinstance(row, dict)]}
else:
raise HTTPException(status_code=400, detail="topic_presets.presets must be an array")
self._write_json_atomic(str(self._topic_presets_templates_file), normalized_topic)
return {
"status": "ok",
"agent_md_templates": self._load_agent_md_templates(),
"topic_presets": self._load_topic_presets_template(),
}
def get_health(self) -> Dict[str, Any]:
try:
with Session(self._engine) as session:
session.exec(select(BotInstance).limit(1)).first()
return {"status": "ok", "database": self._database_engine}
except Exception as exc:
raise HTTPException(status_code=503, detail=f"database check failed: {exc}")
def get_cache_health(self) -> Dict[str, Any]:
redis_url = str(self._redis_url or "").strip()
configured = bool(self._redis_enabled and redis_url)
client_enabled = bool(getattr(self._cache, "enabled", False))
reachable = bool(self._cache.ping()) if client_enabled else False
status = "ok"
if configured and not reachable:
status = "degraded"
return {
"status": status,
"cache": {
"configured": configured,
"enabled": client_enabled,
"reachable": reachable,
"prefix": self._redis_prefix,
},
}

View File

@ -1,12 +1,19 @@
from typing import Any, Dict, List, Optional
from fastapi import Request, UploadFile
from fastapi import HTTPException, Request, UploadFile
from sqlmodel import Session
from models.bot import BotInstance
from providers.selector import get_workspace_provider
class WorkspaceService:
def _require_bot(self, *, session: Session, bot_id: str) -> BotInstance:
bot = session.get(BotInstance, bot_id)
if not bot:
raise HTTPException(status_code=404, detail="Bot not found")
return bot
def list_tree(
self,
*,
@ -66,3 +73,74 @@ class WorkspaceService:
public=public,
redirect_html_to_raw=redirect_html_to_raw,
)
def list_tree_for_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
path: Optional[str] = None,
recursive: bool = False,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.list_tree(app_state=app_state, bot=bot, path=path, recursive=recursive)
def read_file_for_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
path: str,
max_bytes: int = 200000,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.read_file(app_state=app_state, bot=bot, path=path, max_bytes=max_bytes)
def write_markdown_for_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
path: str,
content: str,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return self.write_markdown(app_state=app_state, bot=bot, path=path, content=content)
def serve_file_for_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
path: str,
download: bool,
request: Request,
public: bool = False,
redirect_html_to_raw: bool = False,
):
bot = self._require_bot(session=session, bot_id=bot_id)
return self.serve_file(
app_state=app_state,
bot=bot,
path=path,
download=download,
request=request,
public=public,
redirect_html_to_raw=redirect_html_to_raw,
)
async def upload_files_for_bot(
self,
*,
app_state: Any,
session: Session,
bot_id: str,
files: List[UploadFile],
path: Optional[str] = None,
) -> Dict[str, Any]:
bot = self._require_bot(session=session, bot_id=bot_id)
return await self.upload_files(app_state=app_state, bot=bot, files=files, path=path)

View File

@ -162,6 +162,23 @@ def write_workspace_markdown(
)
@router.put("/api/edge/bots/{bot_id}/workspace/file/text")
def write_workspace_text(
bot_id: str,
path: str = Query(...),
payload: EdgeMarkdownWriteRequest = None,
workspace_root: str | None = None,
):
if payload is None:
raise HTTPException(status_code=400, detail="text payload is required")
return workspace_service_module.edge_workspace_service.write_text_file(
bot_id=bot_id,
path=path,
content=payload.content,
workspace_root=workspace_root,
)
@router.post("/api/edge/bots/{bot_id}/workspace/upload")
async def upload_workspace_files(
bot_id: str,
@ -177,6 +194,19 @@ async def upload_workspace_files(
)
@router.delete("/api/edge/bots/{bot_id}/workspace/file")
def delete_workspace_path(
bot_id: str,
path: str = Query(...),
workspace_root: str | None = None,
):
return workspace_service_module.edge_workspace_service.delete_path(
bot_id=bot_id,
path=path,
workspace_root=workspace_root,
)
@router.get("/api/edge/bots/{bot_id}/workspace/download")
def download_workspace_file(
bot_id: str,

View File

@ -552,6 +552,12 @@ class EdgeDockerManager(EdgeRuntimeBackend):
if response_match:
channel = response_match.group(1).strip().lower()
action_msg = response_match.group(2).strip()
if channel == "dashboard":
return {
"type": "ASSISTANT_MESSAGE",
"channel": "dashboard",
"text": action_msg[:4000],
}
return {
"type": "AGENT_STATE",
"channel": channel,

View File

@ -748,6 +748,12 @@ class EdgeNativeRuntimeBackend(EdgeRuntimeBackend):
if response_match:
channel = response_match.group(1).strip().lower()
action_msg = response_match.group(2).strip()
if channel == "dashboard":
return {
"type": "ASSISTANT_MESSAGE",
"channel": "dashboard",
"text": action_msg[:4000],
}
return {
"type": "AGENT_STATE",
"channel": channel,

View File

@ -39,7 +39,6 @@ class EdgeProvisionService:
"qwen": "dashscope",
"aliyun-qwen": "dashscope",
"moonshot": "kimi",
"vllm": "openai",
"xunfei": "openai",
"iflytek": "openai",
"xfyun": "openai",

View File

@ -57,6 +57,9 @@ class EdgeStateStoreService:
inferred_workspace_root = self._workspace_root_from_runtime_target(primary)
if inferred_workspace_root:
return os.path.abspath(os.path.join(inferred_workspace_root, bot_id, ".nanobot"))
inferred_bot_root = self._bot_root_from_config(primary)
if inferred_bot_root:
return os.path.abspath(os.path.join(inferred_bot_root, ".nanobot"))
return primary
@staticmethod
@ -76,6 +79,33 @@ class EdgeStateStoreService:
except Exception:
return ""
@staticmethod
def _bot_root_from_config(primary_nanobot_root: str) -> str:
path = os.path.join(primary_nanobot_root, "config.json")
if not os.path.isfile(path):
return ""
try:
with open(path, "r", encoding="utf-8") as fh:
payload = json.load(fh)
if not isinstance(payload, dict):
return ""
agents = payload.get("agents")
if not isinstance(agents, dict):
return ""
defaults = agents.get("defaults")
if not isinstance(defaults, dict):
return ""
workspace = str(defaults.get("workspace") or "").strip()
if not workspace:
return ""
normalized_workspace = os.path.abspath(os.path.expanduser(workspace))
suffix = os.path.join(".nanobot", "workspace")
if normalized_workspace.endswith(suffix):
return os.path.abspath(os.path.dirname(os.path.dirname(normalized_workspace)))
except Exception:
return ""
return ""
@classmethod
def _normalize_state_key(cls, state_key: str) -> str:
normalized = str(state_key or "").strip().lower()

View File

@ -104,6 +104,31 @@ class EdgeWorkspaceService:
"content": str(content or ""),
}
def write_text_file(
self,
*,
bot_id: str,
path: str,
content: str,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root)
encoded = str(content or "").encode("utf-8")
if len(encoded) > 2_000_000:
raise HTTPException(status_code=413, detail="text file too large to save")
if "\x00" in str(content or ""):
raise HTTPException(status_code=400, detail="text content contains invalid null bytes")
self._write_text_atomic(target, str(content or ""))
rel_path = os.path.relpath(target, root).replace("\\", "/")
return {
"bot_id": bot_id,
"path": rel_path,
"size": os.path.getsize(target),
"is_markdown": os.path.splitext(target)[1].lower() in {".md", ".markdown"},
"truncated": False,
"content": str(content or ""),
}
async def upload_files(
self,
*,
@ -181,6 +206,25 @@ class EdgeWorkspaceService:
return {"bot_id": bot_id, "files": rows}
def delete_path(
self,
*,
bot_id: str,
path: str,
workspace_root: Optional[str] = None,
) -> Dict[str, Any]:
root, target = self._resolve_workspace_path(bot_id, path, workspace_root=workspace_root)
rel_path = os.path.relpath(target, root).replace("\\", "/")
existed = os.path.exists(target)
if existed:
if os.path.isdir(target):
import shutil
shutil.rmtree(target, ignore_errors=False)
else:
os.remove(target)
return {"bot_id": bot_id, "path": rel_path, "deleted": bool(existed)}
def serve_file(
self,
*,

View File

@ -6,6 +6,7 @@
- 引擎零侵入:不修改 nanobot 源码,仅通过 workspace 与容器管理接入。
- 镜像显式登记:系统不自动构建,不扫描 `engines/`,只使用 Docker 本地镜像 + DB 注册。
- 可观测性优先:通过容器日志流解析状态并推送到 WebSocket。
- 代码结构治理纳入正式架构约束;后续前后端拆分与目录边界以 `design/code-structure-standards.md` 为准。
## 2. 核心组件

View File

@ -0,0 +1,358 @@
# Dashboard Nanobot 代码结构规范(强制执行)
本文档定义后续前端、后端、`dashboard-edge` 的结构边界与拆分规则。
目标不是“尽可能多拆文件”,而是:
- 保持装配层足够薄
- 保持业务边界清晰
- 避免再次出现单文件多职责膨胀
- 让后续迭代继续走低风险、小步验证路线
本文档自落地起作为**后续开发强制规范**执行。
---
## 1. 总原则
### 1.1 先分层,再分文件
- 优先先把“页面装配 / 业务编排 / 基础设施 / 纯视图”分开,再决定是否继续拆文件。
- 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。
- 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。
### 1.2 低风险重构优先
- 结构重构优先做“搬运与收口”,不顺手修改业务行为。
- 同一轮改动里,默认**不要**同时做:
- 大规模结构调整
- 新功能
- 行为修复
- 如果确实需要行为修复,只允许修复拆分直接引入的问题。
### 1.3 装配层必须薄
- 页面层、路由层、应用启动层都只负责装配。
- 装配层可以做依赖注入、状态接线、事件转发。
- 装配层不允许承载复杂业务判断、持久化细节、长流程编排。
### 1.4 新文件必须按主题命名
- 文件名必须直接表达职责。
- 禁止模糊命名,例如:
- `helpers2.py`
- `misc.ts`
- `commonPage.tsx`
- `temp_service.py`
---
## 2. 前端结构规范
### 2.1 目录分层
前端统一按以下层次组织:
- `frontend/src/app`
- 应用壳、全局路由视图、全局初始化
- `frontend/src/modules/<domain>`
- 领域模块入口
- `frontend/src/modules/<domain>/components`
- 纯视图组件、弹层、区块组件
- `frontend/src/modules/<domain>/hooks`
- 领域内控制器 hook、状态编排 hook
- `frontend/src/modules/<domain>/api`
- 仅该领域使用的 API 请求封装
- `frontend/src/modules/<domain>/shared`
- 领域内共享的纯函数、常量、类型桥接
- `frontend/src/components`
- 跨模块通用 UI 组件
- `frontend/src/utils`
- 真正跨领域的通用工具
### 2.2 页面文件职责
页面文件如:
- `frontend/src/modules/platform/PlatformDashboardPage.tsx`
- `frontend/src/modules/platform/NodeWorkspacePage.tsx`
- `frontend/src/modules/platform/NodeHomePage.tsx`
必须遵守:
- 只做页面装配
- 只组织已有区块、弹层、控制器 hook
- 不直接承载长段 API 请求、副作用、数据清洗逻辑
页面文件目标体量:
- 目标:`< 250`
- 可接受上限:`350` 行
- 超过 `350` 行必须优先拆出页面控制器 hook 或区块装配组件
### 2.3 控制器 hook 规范
控制器 hook 用于承载:
- 页面状态
- 副作用
- API 调用编排
- 事件处理
- 派生数据
典型命名:
- `useNodeHomePage`
- `useNodeWorkspacePage`
- `usePlatformDashboardPage`
规则:
- 一个 hook 只服务一个明确页面或一个明确子流程
- hook 不直接产出大量 JSX
- hook 内部允许组合更小的子 hook但不要为了拆分而拆分
控制器 hook 目标体量:
- 目标:`< 350`
- 可接受上限:`500` 行
- 超过 `500` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api`
### 2.4 视图组件规范
组件分为两类:
- 区块组件:例如列表区、详情区、摘要卡片区
- 弹层组件:例如 Drawer、Modal、Sheet
规则:
- 视图组件默认不直接请求接口
- 视图组件只接收已经整理好的 props
- 纯视图组件内部不保留与页面强耦合的业务缓存
### 2.5 前端复用原则
- 优先提炼“稳定复用的模式”,不要提炼“碰巧重复一次的代码”
- 三处以上重复,优先考虑抽取
- 同域复用优先放 `modules/<domain>/shared`
- 跨域复用优先放 `src/components``src/utils`
### 2.6 前端禁止事项
- 禁止再次把页面做成“一个文件管状态、接口、弹层、列表、详情、搜索、分页”
- 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件
- 禁止创建无明确职责的超通用组件
- 禁止为减少行数而做不可读的过度抽象
---
## 3. 后端结构规范
### 3.1 目录分层
后端统一按以下边界组织:
- `backend/main.py`
- 仅启动入口
- `backend/app_factory.py`
- 应用实例创建
- `backend/bootstrap`
- 依赖装配、应用初始化、生命周期拼装
- `backend/api`
- FastAPI 路由层
- `backend/services`
- 业务用例与领域服务
- `backend/core`
- 数据库、缓存、配置、基础设施适配
- `backend/models`
- ORM 模型
- `backend/schemas`
- 请求/响应 DTO
- `backend/providers`
- runtime/workspace/provision 适配层
### 3.2 启动与装配层规范
以下文件必须保持装配层属性:
- `backend/main.py`
- `backend/app_factory.py`
- `backend/bootstrap/app_runtime.py`
规则:
- 只做依赖创建、注入、路由注册、生命周期绑定
- 不写业务 SQL
- 不写领域规则判断
- 不写长流程编排
### 3.3 Router 规范
`backend/api/*.py` 只允许承担:
- HTTP 参数接收
- schema 校验
- 调用 service
- 把领域异常转换成 HTTP 异常
Router 不允许承担:
- 多步业务编排
- 大量数据聚合
- 数据库表间拼装
- 本地文件系统读写细节
Router 文件体量规则:
- 目标:`< 300`
- 可接受上限:`400` 行
- 超过 `400` 行必须拆成子 router并由装配层统一 `include_router`
### 3.4 Service 规范
Service 必须按业务主题拆分。
允许的 service 类型:
- `*_settings_service.py`
- `*_usage_service.py`
- `*_activity_service.py`
- `*_analytics_service.py`
- `*_overview_service.py`
- `*_query_service.py`
- `*_command_service.py`
- `*_lifecycle_service.py`
Service 文件规则:
- 一个文件只负责一个主题
- 同一文件内允许有私有 helper但 helper 只能服务当前主题
- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service
Service 体量规则:
- 目标:`< 350`
- 可接受上限:`500` 行
- 超过 `500` 行必须继续拆
### 3.5 当前 platform 域的标准拆法
`platform` 域后续固定按如下职责组织:
- `backend/api/platform_router.py`
- 仅负责平台路由总装配
- `backend/api/platform_admin_router.py`
- 仅负责平台后台管理路由装配
- `backend/api/platform_overview_router.py`
- 平台概览、统计、事件、usage、缓存刷新
- `backend/api/platform_settings_router.py`
- 平台设置、system settings
- `backend/api/platform_nodes_router.py`
- 节点相关路由总装配
- `backend/api/platform_node_catalog_router.py`
- 节点列表、增删改、连通性测试
- `backend/api/platform_node_probe_router.py`
- 节点探测、心跳、自检类接口
- `backend/api/platform_node_resource_router.py`
- 节点资源、工作区、运行态资源接口
- `backend/services/platform_settings_service.py`
- 平台设置与系统设置
- `backend/services/platform_usage_service.py`
- request usage 记账与查询
- `backend/services/platform_activity_service.py`
- activity event 记录与清理
- `backend/services/platform_analytics_service.py`
- dashboard analytics 聚合
- `backend/services/platform_overview_service.py`
- platform / node overview 聚合
- `backend/services/platform_common.py`
- 仅放当前 platform 域内部共享的纯工具
`backend/services/platform_service.py` 只允许保留为**薄兼容导出层**,不得再新增业务逻辑。
`backend/api/platform_admin_router.py``backend/api/platform_nodes_router.py` 只允许继续承担子路由装配职责,不得重新回填具体业务接口实现。
### 3.6 Schema 规范
- `schemas` 只定义 DTO
- 不允许在 schema 中直接读数据库、读文件、发网络请求
- schema 字段演进必须保持前后端契约可追踪
### 3.7 Core 规范
`core` 只允许放:
- 数据库与 Session 管理
- 缓存
- 配置
- 基础设施适配器
不允许把领域业务塞回 `core` 来“躲避 service 变大”。
### 3.8 Provider 规范
`providers` 只处理运行时/工作区/部署目标差异。
不允许把平台业务逻辑塞进 provider。
### 3.9 dashboard-edge 规范
`dashboard-edge` 按与主后端相同的规则执行:
- `app/main.py` 仅启动
- `app/api/router.py` 仅路由
- `app/services` 仅业务编排
- `app/runtime` 仅 runtime 适配
`dashboard-edge/app/runtime/docker_manager.py``dashboard-edge/app/runtime/native_manager.py`
后续必须按以下方向拆分:
- 生命周期控制
- 资源采样
- preflight / 诊断
- workspace / 文件交互
---
## 4. 本项目后续开发的执行规则
### 4.1 每轮改动的默认顺序
1. 先审计职责边界
2. 先做装配层变薄
3. 再提炼稳定复用块
4. 最后再考虑继续细拆
### 4.2 校验规则
- 前端结构改动后,默认执行 `frontend` 构建校验
- 后端结构改动后,默认至少执行 `python3 -m py_compile`
- 如果改动触达运行时或边界协议,再考虑追加更高层验证
### 4.3 文档同步规则
以下情况必须同步设计文档:
- 新增一层目录边界
- 新增一个领域的标准拆法
- 改变页面/服务的职责划分
- 把兼容层正式降级为装配/导出层
### 4.4 禁止事项
- 禁止回到“大文件集中堆功能”的开发方式
- 禁止为了图省事把新逻辑加回兼容层
- 禁止在没有明确复用收益时过度抽象
- 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议
---
## 5. 当前执行基线2026-03
当前结构治理目标分两层:
- 第一层:主入口、页面入口、路由入口必须变薄
- 第二层:领域内部的 service / hook / overlays / sections 必须按主题稳定收口
后续所有新增功能与重构,均以本文档为准执行。

View File

@ -2,6 +2,10 @@
本文档用于指导当前项目的结构性重构,并为后续“支持同机/远端龙虾 + Docker/Native 双运行模式”升级提前抽离边界。
补充约束:本路线图负责说明“为什么拆、先拆什么”;具体“怎么拆、拆到什么边界”为强制执行项,统一以下文为准:
- `design/code-structure-standards.md`
目标不是一次性大改所有代码,而是先把未来 2 个核心问题理顺:
- 当前前端/后端过于集中,后续功能迭代成本越来越高

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,530 @@
.mobile-user-shell {
padding: 0;
background:
linear-gradient(180deg, color-mix(in oklab, var(--bg) 96%, white 4%) 0%, color-mix(in oklab, var(--bg) 88%, var(--panel-soft) 12%) 100%);
}
.app-shell.mobile-user-shell {
padding: 0;
}
.mobile-user-frame {
width: 100%;
height: 100dvh;
min-height: 100dvh;
display: grid;
grid-template-rows: auto minmax(0, 1fr) auto;
overflow: hidden;
}
.mobile-user-header {
width: 100%;
box-sizing: border-box;
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
min-height: 48px;
padding: 4px max(8px, env(safe-area-inset-right)) 4px max(8px, env(safe-area-inset-left));
border-bottom: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
background: color-mix(in oklab, var(--panel) 96%, transparent);
backdrop-filter: blur(10px);
}
.mobile-user-brand {
display: inline-flex;
align-items: center;
gap: 10px;
min-width: 0;
}
.mobile-user-brand-logo {
width: 30px;
height: 30px;
}
.mobile-user-brand strong {
color: var(--title);
font-size: 22px;
line-height: 1;
}
.mobile-user-header-actions {
display: inline-flex;
align-items: center;
gap: 10px;
}
.compact-header-switches {
display: inline-flex;
align-items: center;
gap: 8px;
}
.compact-header-pill {
width: 40px;
height: 40px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
color: var(--title);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.compact-header-pill-text {
width: auto;
min-width: 52px;
padding: 0 14px;
font-size: 13px;
font-weight: 800;
}
.mobile-user-header-bot-switcher {
min-width: 40px;
}
.mobile-user-header-action,
.mobile-user-avatar {
width: 36px;
height: 36px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
color: var(--title);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
}
.mobile-user-avatar {
font-size: 15px;
font-weight: 800;
}
.mobile-user-content {
min-height: 0;
overflow: hidden;
padding: 3px max(6px, env(safe-area-inset-right)) 0 max(6px, env(safe-area-inset-left));
background: var(--panel);
}
.mobile-user-content > * {
min-height: 100%;
}
.mobile-user-content .chat-workspace-page,
.mobile-user-content .chat-workspace-main,
.mobile-user-content .chat-workspace-main-attached,
.mobile-user-content .grid-ops.grid-ops-compact,
.mobile-user-content .ops-chat-panel,
.mobile-user-content .ops-chat-shell,
.mobile-user-content .ops-main-content-shell,
.mobile-user-content .ops-main-content-frame,
.mobile-user-content .ops-main-content-body,
.mobile-user-content .ops-chat-frame,
.mobile-user-content .ops-runtime-panel,
.mobile-user-content .ops-runtime-shell {
min-height: 0;
height: 100%;
}
.mobile-user-content .ops-chat-scroll {
min-height: 0;
max-height: none;
}
.mobile-user-content .platform-page-stack,
.mobile-user-content .platform-template-page {
min-height: 100%;
gap: 4px;
}
.mobile-user-content .platform-page-stack > .panel,
.mobile-user-content > .platform-template-page,
.mobile-user-content .ops-chat-panel.panel,
.mobile-user-content .ops-runtime-panel.panel {
border: 0;
border-radius: 0;
box-shadow: none;
background: transparent;
padding-top: 5px;
padding-bottom: 5px;
padding-left: 0;
padding-right: 0;
}
.mobile-user-content .page-section-head {
gap: 4px;
}
.mobile-user-content .platform-settings-toolbar,
.mobile-user-content .platform-profile-grid,
.mobile-user-content .platform-profile-meta,
.mobile-user-content .platform-profile-actions,
.mobile-user-content .platform-settings-actions,
.mobile-user-content .platform-settings-pager {
gap: 4px;
}
.mobile-user-content .platform-settings-info-card {
gap: 12px;
padding: 6px 14px;
border-radius: 14px;
}
.mobile-user-content .platform-profile-meta-row {
padding: 5px 12px;
}
.mobile-user-content .platform-profile-actions {
margin-top: 5px;
}
.mobile-user-content .ops-chat-panel,
.mobile-user-content .ops-runtime-panel {
padding: 0;
}
.mobile-user-content .ops-main-content-frame {
border: 1px solid color-mix(in oklab, var(--line) 52%, transparent);
border-radius: 12px;
background: var(--panel);
box-shadow: none;
overflow: hidden;
}
.mobile-user-content .ops-main-content-head {
min-height: 36px;
padding: 3px 4px 3px;
border-bottom: 0;
background: transparent;
}
.mobile-user-content .ops-main-mode-rail {
width: 100%;
}
.mobile-user-content .ops-main-content-body .ops-chat-scroll {
border: 0;
border-radius: 0;
background: transparent;
padding: 2px 4px;
}
.mobile-user-content .ops-main-content-body .ops-chat-dock,
.mobile-user-content .ops-main-content-body .ops-topic-feed.is-panel {
padding-left: 4px;
padding-right: 4px;
}
.mobile-user-content .ops-main-content-body .ops-chat-dock {
padding-top: 1px;
padding-bottom: 4px;
}
.mobile-user-content .ops-main-content-body .ops-topic-feed.is-panel {
padding-top: 3px;
padding-bottom: 4px;
}
.mobile-user-content .ops-topic-feed-list.is-panel {
padding-right: 0;
}
.mobile-user-content .ops-chat-item {
gap: 6px;
}
.mobile-user-content .ops-chat-row {
margin-bottom: 4px;
}
.mobile-user-content .ops-chat-date-divider {
margin: 2px 0 5px;
}
.mobile-user-content .ops-avatar {
width: 28px;
height: 28px;
flex: 0 0 28px;
}
.mobile-user-content .ops-chat-row.is-user .ops-avatar.user {
margin-left: 6px;
}
.mobile-user-content .ops-composer {
border: 0;
border-radius: 0;
background: transparent;
padding: 0;
}
.mobile-user-content .ops-chat-bubble {
max-width: calc(100% - 2px);
}
.mobile-user-content .ops-thinking-bubble {
min-width: 0;
max-width: calc(100% - 2px);
}
.mobile-user-content .ops-runtime-shell {
gap: 4px;
}
.mobile-user-content .ops-runtime-head {
gap: 4px;
}
.mobile-user-content .ops-runtime-scroll {
gap: 4px;
}
.mobile-user-content .workspace-panel {
min-height: 0;
}
.mobile-user-bottom-nav {
position: relative;
width: 100%;
box-sizing: border-box;
display: grid;
grid-template-columns: repeat(4, minmax(0, 1fr));
gap: 6px;
padding:
3px
max(6px, env(safe-area-inset-right))
calc(3px + env(safe-area-inset-bottom))
max(6px, env(safe-area-inset-left));
border-top: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
background: color-mix(in oklab, var(--panel) 97%, transparent);
backdrop-filter: blur(12px);
}
.mobile-user-bottom-item-wrap {
position: relative;
}
.bot-switcher-wrap {
position: relative;
}
.bot-switcher-trigger {
width: 40px;
height: 40px;
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
border-radius: 999px;
background: color-mix(in oklab, var(--panel-soft) 76%, transparent);
color: var(--title);
display: inline-flex;
align-items: center;
justify-content: center;
cursor: pointer;
padding: 0;
}
.bot-switcher-trigger-avatar,
.bot-switcher-item-avatar {
width: 24px;
height: 24px;
border-radius: 999px;
background: color-mix(in oklab, var(--panel) 82%, white 18%);
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 900;
}
.bot-switcher-trigger.is-running {
background: linear-gradient(145deg, color-mix(in oklab, var(--ok) 14%, var(--panel-soft) 86%), color-mix(in oklab, var(--ok) 8%, var(--panel) 92%));
}
.bot-switcher-trigger.is-stopped {
background: linear-gradient(145deg, color-mix(in oklab, #b79aa2 14%, var(--panel-soft) 86%), color-mix(in oklab, #b79aa2 7%, var(--panel) 93%));
}
.bot-switcher-trigger.is-disabled {
background: linear-gradient(145deg, color-mix(in oklab, #9ca3b5 14%, var(--panel-soft) 86%), color-mix(in oklab, #9ca3b5 7%, var(--panel) 93%));
}
.bot-switcher-trigger.is-open {
border-color: color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
}
.bot-switcher-overlay {
position: fixed;
inset: 0;
background: rgba(3, 9, 20, 0.46);
display: flex;
align-items: center;
justify-content: center;
padding: 24px;
z-index: 9999;
}
.bot-switcher-portal-shell {
padding: 0;
min-height: 0;
}
.bot-switcher-popover {
width: min(420px, calc(100vw - 32px));
max-height: min(520px, calc(100vh - 48px));
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
border-radius: 22px;
background: color-mix(in oklab, var(--panel) 98%, transparent);
box-shadow: var(--shadow);
padding: 14px;
display: grid;
gap: 12px;
margin: auto;
}
.bot-switcher-popover-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
color: var(--muted);
font-size: 12px;
}
.bot-switcher-popover-head strong {
color: var(--title);
font-size: 13px;
}
.bot-switcher-popover-list {
min-height: 0;
overflow: auto;
display: grid;
gap: 8px;
}
.bot-switcher-item {
width: 100%;
border: 1px solid color-mix(in oklab, var(--line) 72%, transparent);
border-radius: 14px;
padding: 10px 12px;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
cursor: pointer;
color: var(--text);
background: color-mix(in oklab, var(--panel-soft) 70%, transparent);
}
.bot-switcher-item.is-running {
background: linear-gradient(145deg, color-mix(in oklab, var(--ok) 14%, var(--panel-soft) 86%), color-mix(in oklab, var(--ok) 8%, var(--panel) 92%));
}
.bot-switcher-item.is-stopped {
background: linear-gradient(145deg, color-mix(in oklab, #b79aa2 14%, var(--panel-soft) 86%), color-mix(in oklab, #b79aa2 7%, var(--panel) 93%));
}
.bot-switcher-item.is-disabled {
background: linear-gradient(145deg, color-mix(in oklab, #9ca3b5 14%, var(--panel-soft) 86%), color-mix(in oklab, #9ca3b5 7%, var(--panel) 93%));
}
.bot-switcher-item.is-active {
border-color: color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--brand) 52%, transparent);
}
.bot-switcher-item-main {
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
}
.bot-switcher-item-copy {
min-width: 0;
display: grid;
gap: 3px;
text-align: left;
}
.bot-switcher-item-copy strong {
color: var(--title);
font-size: 13px;
}
.bot-switcher-item-copy span {
color: var(--muted);
font-size: 12px;
}
.sys-topbar-bot-switcher {
min-width: 40px;
}
.sys-topbar-bot-switcher .bot-switcher-trigger {
width: 40px;
height: 40px;
}
.mobile-user-bottom-item {
width: 100%;
min-height: 52px;
border: 0;
background: transparent;
color: var(--muted);
display: grid;
justify-items: center;
gap: 4px;
align-content: center;
cursor: pointer;
font-size: 12px;
font-weight: 700;
}
.mobile-user-bottom-item.is-active {
color: var(--brand);
}
.mobile-user-settings-popover {
position: absolute;
right: 0;
bottom: calc(100% + 10px);
min-width: 190px;
padding: 8px;
border: 1px solid color-mix(in oklab, var(--line) 76%, transparent);
border-radius: 18px;
background: color-mix(in oklab, var(--panel) 98%, transparent);
box-shadow: var(--shadow);
display: grid;
gap: 6px;
z-index: 30;
}
.mobile-user-settings-item {
width: 100%;
border: 1px solid transparent;
border-radius: 12px;
background: transparent;
color: var(--text);
padding: 10px 12px;
display: inline-flex;
align-items: center;
gap: 8px;
cursor: pointer;
font-size: 13px;
font-weight: 700;
}
.mobile-user-settings-item:hover {
background: color-mix(in oklab, var(--panel-soft) 52%, transparent);
border-color: color-mix(in oklab, var(--brand) 28%, transparent);
}
.mobile-user-settings-item.danger {
color: var(--err);
}

View File

@ -1,446 +1,205 @@
import { useEffect, useState, type ReactElement } from 'react';
import { useEffect, useState } from 'react';
import axios from 'axios';
import { ChevronDown, ChevronUp, MoonStar, SunMedium } from 'lucide-react';
import { useAppStore } from './store/appStore';
import { useBotsSync } from './hooks/useBotsSync';
import { APP_ENDPOINTS } from './config/env';
import { pickLocale } from './i18n';
import { appZhCn } from './i18n/app.zh-cn';
import { appEn } from './i18n/app.en';
import { LucentTooltip } from './components/lucent/LucentTooltip';
import { PasswordInput } from './components/PasswordInput';
import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess';
import {
PANEL_AUTH_REQUIRED_EVENT,
clearPanelAccessPassword,
getPanelAccessPassword,
setPanelAccessPassword,
} from './utils/panelAccess';
import { BotHomePage } from './modules/bot-home/BotHomePage';
import { NodeHomePage } from './modules/platform/NodeHomePage';
import { NodeWorkspacePage } from './modules/platform/NodeWorkspacePage';
import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal';
import { readCompactModeFromUrl, useAppRoute } from './utils/appRoute';
import { useAppStore } from './store/appStore';
import { clearSessionToken, getSessionToken, SESSION_AUTH_REQUIRED_EVENT, setSessionToken } from './utils/sessionAuth';
import { readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute';
import type { SysAuthBootstrap } from './types/sys';
import { DashboardLogin } from './app/AppChrome';
import { AuthenticatedDashboardApp } from './app/AppShellViews';
import { getRouteMeta, navigateTo } from './app/appRouteMeta';
import './App.css';
import './App.mobile.css';
const defaultLoadingPage = {
title: 'Dashboard Nanobot',
subtitle: '平台正在准备管理面板',
description: '请稍候,正在加载 Bot 平台数据。',
};
function AuthenticatedApp() {
const route = useAppRoute();
const { theme, setTheme, locale, setLocale, activeBots } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
function SessionShell({ route }: { route: AppRoute }) {
const { theme, locale } = useAppStore();
const isZh = locale === 'zh';
const [viewportCompact, setViewportCompact] = useState(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(max-width: 980px)').matches;
return readCompactModeFromUrl() || window.matchMedia('(max-width: 980px)').matches;
});
const [headerCollapsed, setHeaderCollapsed] = useState(false);
const [singleBotPassword, setSingleBotPassword] = useState('');
const [singleBotPasswordError, setSingleBotPasswordError] = useState('');
const [singleBotUnlocked, setSingleBotUnlocked] = useState(false);
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const forcedBotId = route.kind === 'bot' ? route.botId : '';
const forcedNodeId = route.kind === 'dashboard-node' ? route.nodeId : '';
useBotsSync(forcedBotId || undefined);
const [sidebarOpen, setSidebarOpen] = useState(false);
const [checking, setChecking] = useState(true);
const [submitting, setSubmitting] = useState(false);
const [authBootstrap, setAuthBootstrap] = useState<SysAuthBootstrap | null>(null);
const [username, setUsername] = useState('');
const [password, setPassword] = useState('');
const [defaultUsername, setDefaultUsername] = useState('admin');
const [error, setError] = useState('');
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(max-width: 980px)');
const apply = () => setViewportCompact(media.matches);
const apply = () => setViewportCompact(readCompactModeFromUrl() || media.matches);
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, []);
useEffect(() => {
setHeaderCollapsed(readCompactModeFromUrl() || viewportCompact);
}, [viewportCompact, route.kind, forcedBotId]);
const compactMode = readCompactModeFromUrl() || viewportCompact;
const isCompactShell = compactMode;
const hideHeader = route.kind === 'dashboard' && compactMode;
const forcedBot = forcedBotId ? activeBots[forcedBotId] : undefined;
const shouldPromptSingleBotPassword = Boolean(
route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked,
);
const headerTitle =
route.kind === 'bot'
? (forcedBot?.name || defaultLoadingPage.title)
: route.kind === 'dashboard-node'
? `${t.nodeWorkspace} · ${forcedNodeId || 'local'}`
: route.kind === 'dashboard-skills'
? t.skillMarketplace
: t.title;
useEffect(() => {
if (route.kind === 'dashboard') {
document.title = t.title;
return;
}
if (route.kind === 'dashboard-skills') {
document.title = `${t.title} - ${t.skillMarketplace}`;
return;
}
if (route.kind === 'dashboard-node') {
document.title = `${t.title} - ${t.nodeWorkspace} - ${forcedNodeId || 'local'}`;
return;
}
const botName = String(forcedBot?.name || '').trim();
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forcedBotId}`;
}, [forcedBot?.name, forcedBotId, forcedNodeId, route.kind, t.nodeWorkspace, t.skillMarketplace, t.title]);
useEffect(() => {
setSingleBotUnlocked(false);
setSingleBotPassword('');
setSingleBotPasswordError('');
}, [forcedBotId]);
useEffect(() => {
if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return;
const stored = getBotAccessPassword(forcedBotId);
if (!stored) return;
let alive = true;
let retryTimer: number | null = null;
const boot = async () => {
let keepChecking = false;
setChecking(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored });
const status = await axios.get<{ default_username?: string }>(`${APP_ENDPOINTS.apiBase}/sys/auth/status`);
if (!alive) return;
setBotAccessPassword(forcedBotId, stored);
setSingleBotUnlocked(true);
setSingleBotPassword('');
setSingleBotPasswordError('');
} catch {
clearBotAccessPassword(forcedBotId);
setDefaultUsername(String(status.data?.default_username || 'admin'));
const token = getSessionToken();
if (!token) {
setChecking(false);
return;
}
const me = await axios.get<SysAuthBootstrap>(`${APP_ENDPOINTS.apiBase}/sys/auth/me`);
if (!alive) return;
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.');
setAuthBootstrap({ ...me.data, token });
setError('');
} catch (error: any) {
const status = Number(error?.response?.status || 0);
const hasToken = Boolean(getSessionToken());
if (status === 401) {
clearSessionToken();
if (!alive) return;
setAuthBootstrap(null);
} else if (alive && hasToken) {
keepChecking = true;
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
}
retryTimer = window.setTimeout(() => {
retryTimer = null;
void boot();
}, 2000);
}
} finally {
if (alive) setChecking(keepChecking);
}
};
void boot();
return () => {
alive = false;
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
}
};
}, [forcedBot?.has_access_password, forcedBotId, locale, route.kind, singleBotUnlocked]);
}, []);
const unlockSingleBot = async () => {
const entered = String(singleBotPassword || '').trim();
if (!entered || route.kind !== 'bot' || !forcedBotId) {
setSingleBotPasswordError(locale === 'zh' ? '请输入 Bot 密码。' : 'Enter the bot password.');
return;
}
setSingleBotSubmitting(true);
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: entered });
setBotAccessPassword(forcedBotId, entered);
setSingleBotPasswordError('');
setSingleBotUnlocked(true);
setSingleBotPassword('');
} catch {
clearBotAccessPassword(forcedBotId);
setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重试。' : 'Invalid bot password. Please try again.');
} finally {
setSingleBotSubmitting(false);
}
};
const navigateToDashboard = () => {
useEffect(() => {
if (typeof window === 'undefined') return;
window.history.pushState({}, '', '/dashboard');
window.dispatchEvent(new PopStateEvent('popstate'));
};
return (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className={`app-frame ${hideHeader ? 'app-frame-no-header' : ''}`}>
{!hideHeader ? (
<header
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
onClick={() => {
if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
}}
>
<div className="row-between app-header-top">
<div className="app-title">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div className="app-title-main">
<h1>{headerTitle}</h1>
{route.kind === 'dashboard-skills' ? (
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
{t.platformHome}
</button>
) : route.kind === 'dashboard-node' ? (
<button type="button" className="app-route-subtitle app-route-crumb" onClick={navigateToDashboard}>
{t.platformHome}
</button>
) : (
<div className="app-route-subtitle">
{route.kind === 'dashboard'
? t.platformHome
: t.botHome}
</div>
)}
{isCompactShell ? (
<button
type="button"
className="app-header-toggle-inline"
onClick={(event) => {
event.stopPropagation();
setHeaderCollapsed((value) => !value);
}}
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
>
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
) : null}
</div>
</div>
<div className="app-header-actions">
{!headerCollapsed ? (
<div className="global-switches">
<div className="switch-compact">
<LucentTooltip content={t.dark}>
<button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
<MoonStar size={14} />
</button>
</LucentTooltip>
<LucentTooltip content={t.light}>
<button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
<SunMedium size={14} />
</button>
</LucentTooltip>
</div>
<div className="switch-compact">
<LucentTooltip content={t.zh}>
<button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
<span>ZH</span>
</button>
</LucentTooltip>
<LucentTooltip content={t.en}>
<button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
<span>EN</span>
</button>
</LucentTooltip>
</div>
</div>
) : null}
</div>
</div>
</header>
) : null}
<main className="main-stage">
{route.kind === 'dashboard' ? (
<NodeHomePage compactMode={compactMode} />
) : route.kind === 'dashboard-node' ? (
<NodeWorkspacePage nodeId={forcedNodeId} compactMode={compactMode} />
) : route.kind === 'dashboard-skills' ? (
<SkillMarketManagerPage isZh={locale === 'zh'} />
) : (
<BotHomePage botId={forcedBotId} compactMode={compactMode} />
)}
</main>
</div>
{shouldPromptSingleBotPassword ? (
<div className="modal-mask app-modal-mask">
<div className="app-login-card" onClick={(event) => event.stopPropagation()}>
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{forcedBot?.name || forcedBotId}</h1>
<p>{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}</p>
<div className="app-login-form">
<PasswordInput
className="input"
value={singleBotPassword}
onChange={(event) => {
setSingleBotPassword(event.target.value);
if (singleBotPasswordError) setSingleBotPasswordError('');
}}
onKeyDown={(event) => {
if (event.key === 'Enter') void unlockSingleBot();
}}
placeholder={locale === 'zh' ? 'Bot 密码' : 'Bot password'}
autoFocus
toggleLabels={passwordToggleLabels}
/>
{singleBotPasswordError ? <div className="app-login-error">{singleBotPasswordError}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void unlockSingleBot()} disabled={singleBotSubmitting}>
{singleBotSubmitting ? (locale === 'zh' ? '校验中...' : 'Checking...') : (locale === 'zh' ? '进入' : 'Continue')}
</button>
</div>
</div>
</div>
) : null}
</div>
);
}
function PanelLoginGate({ children }: { children: ReactElement }) {
const route = useAppRoute();
const { theme, locale } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const [checking, setChecking] = useState(true);
const [required, setRequired] = useState(false);
const [authenticated, setAuthenticated] = useState(false);
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
const bypassPanelGate = route.kind === 'bot';
useEffect(() => {
if (bypassPanelGate) {
setRequired(false);
setAuthenticated(true);
setChecking(false);
return;
}
let alive = true;
const boot = async () => {
try {
const status = await axios.get<{ enabled: boolean }>(`${APP_ENDPOINTS.apiBase}/panel/auth/status`);
if (!alive) return;
const enabled = Boolean(status.data?.enabled);
if (!enabled) {
setRequired(false);
setAuthenticated(true);
setChecking(false);
return;
}
setRequired(true);
const stored = getPanelAccessPassword();
if (!stored) {
setChecking(false);
return;
}
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: stored });
if (!alive) return;
setAuthenticated(true);
} catch {
clearPanelAccessPassword();
if (!alive) return;
setError(locale === 'zh' ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.');
} finally {
if (alive) setChecking(false);
}
} catch {
if (!alive) return;
setRequired(true);
setAuthenticated(false);
setError(
locale === 'zh'
? '无法确认面板访问状态,请重新输入面板密码。若仍失败,请检查 Dashboard Backend 是否已重启并应用最新配置。'
: 'Unable to verify panel access. Enter the panel password again. If it still fails, restart the Dashboard backend and ensure the latest config is loaded.',
);
setChecking(false);
}
};
void boot();
return () => {
alive = false;
};
}, [bypassPanelGate, locale]);
useEffect(() => {
if (typeof window === 'undefined' || bypassPanelGate) return;
const onPanelAuthRequired = (event: Event) => {
const onAuthRequired = (event: Event) => {
const detail = String((event as CustomEvent<string>)?.detail || '').trim();
setRequired(true);
setAuthenticated(false);
setChecking(false);
setSubmitting(false);
clearSessionToken();
setAuthBootstrap(null);
setPassword('');
setError(
detail || (locale === 'zh' ? '面板访问密码已失效,请重新输入。' : 'Panel access password expired. Please sign in again.'),
);
setError(detail || (isZh ? '登录状态已失效,请重新登录。' : 'Session expired. Please sign in again.'));
};
window.addEventListener(PANEL_AUTH_REQUIRED_EVENT, onPanelAuthRequired as EventListener);
return () => window.removeEventListener(PANEL_AUTH_REQUIRED_EVENT, onPanelAuthRequired as EventListener);
}, [bypassPanelGate, locale]);
window.addEventListener(SESSION_AUTH_REQUIRED_EVENT, onAuthRequired as EventListener);
return () => window.removeEventListener(SESSION_AUTH_REQUIRED_EVENT, onAuthRequired as EventListener);
}, [isZh]);
const onSubmit = async () => {
const next = String(password || '').trim();
if (!next) {
setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access password.');
useEffect(() => {
setSidebarOpen(false);
}, [route.kind]);
const handleLogin = async () => {
const normalizedUsername = String(username || defaultUsername || '').trim().toLowerCase();
if (!normalizedUsername || !password.trim()) {
setError(isZh ? '请输入用户名和密码。' : 'Enter both username and password.');
return;
}
setSubmitting(true);
setError('');
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next });
setPanelAccessPassword(next);
setAuthenticated(true);
} catch {
clearPanelAccessPassword();
setError(locale === 'zh' ? '面板访问密码错误。' : 'Invalid panel access password.');
const res = await axios.post<SysAuthBootstrap>(`${APP_ENDPOINTS.apiBase}/sys/auth/login`, {
username: normalizedUsername,
password,
});
setSessionToken(String(res.data?.token || ''));
setAuthBootstrap(res.data);
setPassword('');
if (route.kind === 'dashboard') {
navigateTo(res.data?.home_path || '/dashboard');
}
} catch (loginError: any) {
setError(loginError?.response?.data?.detail || (isZh ? '用户名或密码错误。' : 'Invalid username or password.'));
} finally {
setSubmitting(false);
}
};
const handleLogout = async () => {
try {
await axios.post(`${APP_ENDPOINTS.apiBase}/sys/auth/logout`);
} catch {
// ignore logout failure and clear local session anyway
} finally {
clearSessionToken();
setAuthBootstrap(null);
setPassword('');
setError('');
}
};
const handleBootstrapChange = (nextBootstrap: SysAuthBootstrap) => {
const currentToken = String(nextBootstrap.token || authBootstrap?.token || getSessionToken() || '');
if (currentToken) setSessionToken(currentToken);
setAuthBootstrap({
...nextBootstrap,
token: currentToken,
expires_at: nextBootstrap.expires_at || authBootstrap?.expires_at || null,
});
};
const routeMeta = getRouteMeta(route, isZh);
const compactMode = viewportCompact;
if (checking) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}</p>
<h1>Nanobot</h1>
<p>{isZh ? '正在检查登录状态...' : 'Checking session...'}</p>
</div>
</div>
</div>
);
}
if (required && !authenticated) {
if (!authBootstrap) {
return (
<div className="app-shell" data-theme={theme}>
<div className="app-login-shell">
<div className="app-login-card">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon" />
<h1>{t.title}</h1>
<p>{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}</p>
<div className="app-login-form">
<PasswordInput
className="input"
value={password}
onChange={(event) => setPassword(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') void onSubmit();
}}
placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'}
toggleLabels={passwordToggleLabels}
/>
{error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit" onClick={() => void onSubmit()} disabled={submitting}>
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
</button>
</div>
</div>
</div>
</div>
<DashboardLogin
username={username}
password={password}
submitting={submitting}
error={error}
onUsernameChange={setUsername}
onPasswordChange={setPassword}
onSubmit={() => void handleLogin()}
defaultUsername={defaultUsername}
/>
);
}
return children;
}
function App() {
return (
<PanelLoginGate>
<AuthenticatedApp />
</PanelLoginGate>
<AuthenticatedDashboardApp
route={route}
authBootstrap={authBootstrap}
compactMode={compactMode}
sidebarOpen={sidebarOpen}
setSidebarOpen={setSidebarOpen}
routeMeta={routeMeta}
onLogout={() => void handleLogout()}
onAuthBootstrapChange={handleBootstrapChange}
/>
);
}
function App() {
const route = useAppRoute();
return <SessionShell route={route} />;
}
export default App;

View File

@ -0,0 +1,362 @@
import { createPortal } from 'react-dom';
import { useEffect, useMemo, useState } from 'react';
import {
BadgeCheck,
Files,
History,
KeyRound,
LayoutDashboard,
LogOut,
MessageCircle,
MoonStar,
Rocket,
Settings2,
Shield,
SunMedium,
UserRound,
Users,
Waypoints,
Wrench,
} from 'lucide-react';
import type { LucideIcon } from 'lucide-react';
import { PasswordInput } from '../components/PasswordInput';
import { pickLocale } from '../i18n';
import { appZhCn } from '../i18n/app.zh-cn';
import { appEn } from '../i18n/app.en';
import { useAppStore } from '../store/appStore';
import type { SysAuthBootstrap, SysMenuItem } from '../types/sys';
const iconMap: Record<string, LucideIcon> = {
'layout-dashboard': LayoutDashboard,
waypoints: Waypoints,
wrench: Wrench,
'sliders-horizontal': Settings2,
files: Files,
rocket: Rocket,
shield: Shield,
users: Users,
'badge-check': BadgeCheck,
'layout-grid': LayoutDashboard,
'message-circle': MessageCircle,
'user-round': UserRound,
history: History,
'key-round': KeyRound,
};
export function ThemeLocaleSwitches() {
const { theme, setTheme, locale, setLocale } = useAppStore();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
return (
<div className="global-switches">
<div className="switch-compact">
<button className={`switch-btn ${theme === 'dark' ? 'active' : ''}`} onClick={() => setTheme('dark')} aria-label={t.dark}>
<MoonStar size={14} />
</button>
<button className={`switch-btn ${theme === 'light' ? 'active' : ''}`} onClick={() => setTheme('light')} aria-label={t.light}>
<SunMedium size={14} />
</button>
</div>
<div className="switch-compact">
<button className={`switch-btn switch-btn-lang ${locale === 'zh' ? 'active' : ''}`} onClick={() => setLocale('zh')} aria-label={t.zh}>
<span>ZH</span>
</button>
<button className={`switch-btn switch-btn-lang ${locale === 'en' ? 'active' : ''}`} onClick={() => setLocale('en')} aria-label={t.en}>
<span>EN</span>
</button>
</div>
</div>
);
}
export function CompactHeaderSwitches() {
const { theme, setTheme, locale, setLocale } = useAppStore();
const isZh = locale === 'zh';
return (
<div className="compact-header-switches">
<button
type="button"
className="compact-header-pill"
onClick={() => setTheme(theme === 'dark' ? 'light' : 'dark')}
aria-label={isZh ? '切换主题' : 'Toggle theme'}
title={isZh ? '切换主题' : 'Toggle theme'}
>
{theme === 'dark' ? <MoonStar size={16} /> : <SunMedium size={16} />}
</button>
<button
type="button"
className="compact-header-pill compact-header-pill-text"
onClick={() => setLocale(isZh ? 'en' : 'zh')}
aria-label={isZh ? '切换语言' : 'Toggle language'}
title={isZh ? '切换语言' : 'Toggle language'}
>
<span>{isZh ? 'ZH' : 'EN'}</span>
</button>
</div>
);
}
type DashboardLoginProps = {
username: string;
password: string;
submitting: boolean;
error: string;
onUsernameChange: (value: string) => void;
onPasswordChange: (value: string) => void;
onSubmit: () => void;
defaultUsername: string;
};
export function DashboardLogin({
username,
password,
submitting,
error,
onUsernameChange,
onPasswordChange,
onSubmit,
defaultUsername,
}: DashboardLoginProps) {
const { theme, locale } = useAppStore();
const passwordToggleLabels = locale === 'zh'
? { show: '显示密码', hide: '隐藏密码' }
: { show: 'Show password', hide: 'Hide password' };
return (
<div className="app-shell sys-login-shell" data-theme={theme}>
<div className="sys-login-layout">
<div className="app-login-card sys-login-card">
<div className="sys-login-brand">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-login-icon sys-login-logo" />
<h1>Nanobot</h1>
<p>{locale === 'zh' ? '用户登录' : 'User Sign In'}</p>
</div>
<div className="app-login-form">
<label className="field-label">{locale === 'zh' ? '用户名' : 'Username'}</label>
<input
className="input"
value={username}
onChange={(event) => onUsernameChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onSubmit();
}}
placeholder={defaultUsername || (locale === 'zh' ? '用户名' : 'Username')}
autoFocus
/>
<label className="field-label">{locale === 'zh' ? '密码' : 'Password'}</label>
<PasswordInput
className="input"
value={password}
onChange={(event) => onPasswordChange(event.target.value)}
onKeyDown={(event) => {
if (event.key === 'Enter') onSubmit();
}}
placeholder={locale === 'zh' ? '密码' : 'Password'}
toggleLabels={passwordToggleLabels}
/>
{error ? <div className="app-login-error">{error}</div> : null}
<button className="btn btn-primary app-login-submit sys-login-submit" onClick={onSubmit} disabled={submitting}>
{submitting ? (locale === 'zh' ? '登录中...' : 'Signing in...') : (locale === 'zh' ? '登录' : 'Sign In')}
</button>
<div className="sys-login-hint">
{locale === 'zh'
? `首次初始化默认账号:${defaultUsername || 'admin'}`
: `Initial seeded account: ${defaultUsername || 'admin'}`}
</div>
</div>
</div>
</div>
</div>
);
}
type SidebarMenuProps = {
menus: SysMenuItem[];
activeMenuKey: string;
onNavigate: (path: string) => void;
};
export function SidebarMenu({ menus, activeMenuKey, onNavigate }: SidebarMenuProps) {
const { locale } = useAppStore();
return (
<nav className="sys-sidebar-nav">
{menus.map((group) => (
<section key={group.menu_key} className="sys-sidebar-group">
<div className="sys-sidebar-group-title">
{locale === 'zh' ? group.title : (group.title_en || group.title)}
</div>
<div className="sys-sidebar-group-items">
{(group.children || []).map((item) => {
const Icon = iconMap[item.icon || 'layout-dashboard'] || LayoutDashboard;
const active = item.menu_key === activeMenuKey;
return (
<button
key={item.menu_key}
type="button"
className={`sys-sidebar-item ${active ? 'is-active' : ''}`}
onClick={() => onNavigate(item.route_path)}
>
<Icon size={16} />
<span>{locale === 'zh' ? item.title : (item.title_en || item.title)}</span>
</button>
);
})}
</div>
</section>
))}
</nav>
);
}
type SidebarAccountPillProps = {
authBootstrap: SysAuthBootstrap;
isZh: boolean;
onOpenProfile: () => void;
onLogout: () => void;
};
export function SidebarAccountPill({ authBootstrap, isZh, onOpenProfile, onLogout }: SidebarAccountPillProps) {
const username = String(authBootstrap.user.display_name || authBootstrap.user.username || '');
const subtitle = authBootstrap.user.role?.name || (isZh ? '账户设置' : 'Account settings');
return (
<div className="sys-user-pill sys-user-pill-sidebar">
<button className="sys-user-pill-main" type="button" onClick={onOpenProfile}>
<span className="sys-user-pill-avatar">{username.slice(0, 1).toUpperCase()}</span>
<span className="sys-user-pill-copy">
<strong>{username}</strong>
<span>{subtitle}</span>
</span>
</button>
<button className="sys-user-pill-action" type="button" onClick={onLogout} aria-label={isZh ? '退出登录' : 'Logout'} title={isZh ? '退出登录' : 'Logout'}>
<LogOut size={14} />
</button>
</div>
);
}
export function isNormalUserRole(authBootstrap: SysAuthBootstrap) {
return String(authBootstrap.user.role?.role_key || '').trim().toLowerCase() === 'normal_user';
}
export function normalizeAssignedBotTone(enabled?: boolean, dockerStatus?: string) {
if (enabled === false) return 'is-disabled';
return String(dockerStatus || '').toUpperCase() === 'RUNNING' ? 'is-running' : 'is-stopped';
}
export function useResolvedAssignedBots(authBootstrap: SysAuthBootstrap) {
const activeBots = useAppStore((state) => state.activeBots);
return useMemo(() => {
const assigned = Array.isArray(authBootstrap.assigned_bots) ? authBootstrap.assigned_bots : [];
if (assigned.length === 0) return [];
const liveBotIds = new Set(Object.keys(activeBots).filter((botId) => String(botId || '').trim()));
const preferLiveIntersection = liveBotIds.size > 0;
return assigned
.filter((item) => !preferLiveIntersection || liveBotIds.has(String(item.id || '').trim()))
.map((item) => {
const live = activeBots[item.id];
return live
? {
id: live.id,
name: live.name || item.name,
enabled: live.enabled,
docker_status: live.docker_status,
node_id: live.node_id || item.node_id,
node_display_name: live.node_display_name || item.node_display_name || item.node_id,
}
: item;
})
.filter((item) => String(item.id || '').trim());
}, [activeBots, authBootstrap.assigned_bots]);
}
type BotSwitcherTriggerProps = {
authBootstrap: SysAuthBootstrap;
isZh: boolean;
selectedBotId: string;
onSelectBot: (botId: string) => void;
className?: string;
};
export function BotSwitcherTrigger({
authBootstrap,
isZh,
selectedBotId,
onSelectBot,
className,
}: BotSwitcherTriggerProps) {
const bots = useResolvedAssignedBots(authBootstrap);
const theme = useAppStore((state) => state.theme);
const [open, setOpen] = useState(false);
const selectedBot = bots.find((bot) => bot.id === selectedBotId) || bots[0];
const shortName = String(selectedBot?.name || selectedBot?.id || '').slice(0, 1).toUpperCase() || 'B';
const tone = normalizeAssignedBotTone(selectedBot?.enabled, selectedBot?.docker_status);
const canRenderPortal = typeof document !== 'undefined';
useEffect(() => {
if (bots.length <= 1) setOpen(false);
}, [bots.length]);
return (
<div className={`bot-switcher-wrap ${className || ''}`}>
<button
type="button"
className={`bot-switcher-trigger ${tone}${open ? ' is-open' : ''}`}
onClick={() => {
if (bots.length > 1) {
setOpen((value) => !value);
}
}}
title={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current Bot')}
aria-label={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current Bot')}
>
<span className="bot-switcher-trigger-avatar">{shortName}</span>
</button>
{open && canRenderPortal ? createPortal(
<div className="app-shell bot-switcher-portal-shell" data-theme={theme}>
<div className="bot-switcher-overlay" onClick={() => setOpen(false)}>
<div className="bot-switcher-popover" onClick={(event) => event.stopPropagation()}>
<div className="bot-switcher-popover-head">
<strong>{isZh ? '切换 Bot' : 'Switch Bot'}</strong>
<span>{isZh ? `${bots.length}` : `${bots.length}`}</span>
</div>
<div className="bot-switcher-popover-list">
{bots.map((bot) => {
const active = bot.id === selectedBot?.id;
const itemTone = normalizeAssignedBotTone(bot.enabled, bot.docker_status);
return (
<button
key={bot.id}
type="button"
className={`bot-switcher-item ${itemTone}${active ? ' is-active' : ''}`}
onClick={() => {
onSelectBot(bot.id);
setOpen(false);
}}
>
<div className="bot-switcher-item-main">
<span className="bot-switcher-item-avatar">{String(bot.name || bot.id || '').slice(0, 1).toUpperCase() || 'B'}</span>
<div className="bot-switcher-item-copy">
<strong>{bot.name || bot.id}</strong>
<span className="mono">{bot.id}</span>
</div>
</div>
<div className="bot-switcher-item-meta">
<span className={`badge ${itemTone === 'is-running' ? 'badge-ok' : itemTone === 'is-disabled' ? 'badge-err' : 'badge-unknown'}`}>
{itemTone === 'is-running' ? (isZh ? '运行中' : 'Running') : itemTone === 'is-disabled' ? (isZh ? '已停用' : 'Disabled') : (isZh ? '已停止' : 'Stopped')}
</span>
</div>
</button>
);
})}
</div>
</div>
</div>
</div>,
document.body,
) : null}
</div>
);
}

View File

@ -0,0 +1,491 @@
import { useEffect, useMemo, useState, type ReactNode } from 'react';
import {
History,
KeyRound,
LayoutDashboard,
Menu,
MessageCircle,
Settings2,
UserRound,
X,
} from 'lucide-react';
import { useBotsSync } from '../hooks/useBotsSync';
import { useAppStore } from '../store/appStore';
import type { SysAuthBootstrap } from '../types/sys';
import type { AppRoute } from '../utils/appRoute';
import { AdminAccessPlaceholderPage } from '../modules/platform/components/AdminAccessPlaceholderPage';
import { PlatformSettingsPage } from '../modules/platform/components/PlatformSettingsPage';
import { RoleManagementPage } from '../modules/platform/components/RoleManagementPage';
import { SkillMarketManagerPage } from '../modules/platform/components/SkillMarketManagerModal';
import { TemplateManagerPage } from '../modules/platform/components/TemplateManagerPage';
import { UserManagementPage } from '../modules/platform/components/UserManagementPage';
import { UserProfilePage } from '../modules/platform/components/UserProfilePage';
import { BotHomePage } from '../modules/bot-home/BotHomePage';
import { ChatWorkspacePage } from '../modules/chat/ChatWorkspacePage';
import { NodeHomePage } from '../modules/platform/NodeHomePage';
import { NodeWorkspacePage } from '../modules/platform/NodeWorkspacePage';
import { PlatformDashboardPage } from '../modules/platform/PlatformDashboardPage';
import { PlatformHomePage } from '../modules/platform/PlatformHomePage';
import type { AppRouteMeta } from './appRouteMeta';
import { collectMenuKeys, navigateTo } from './appRouteMeta';
import {
BotSwitcherTrigger,
CompactHeaderSwitches,
isNormalUserRole,
SidebarAccountPill,
SidebarMenu,
ThemeLocaleSwitches,
useResolvedAssignedBots,
} from './AppChrome';
type PersonalMobileShellProps = {
route: AppRoute;
authBootstrap: SysAuthBootstrap;
onLogout: () => void;
onAuthBootstrapChange: (value: SysAuthBootstrap) => void;
};
export function PersonalMobileShell({
route,
authBootstrap,
onLogout,
onAuthBootstrapChange,
}: PersonalMobileShellProps) {
const { theme, locale } = useAppStore();
const isZh = locale === 'zh';
const [mobilePrimaryTab, setMobilePrimaryTab] = useState<'chat' | 'runtime'>('chat');
const [selectedChatBotId, setSelectedChatBotId] = useState('');
const [settingsOpen, setSettingsOpen] = useState(false);
const assignedBots = useResolvedAssignedBots(authBootstrap);
useEffect(() => {
if (!assignedBots.length) {
setSelectedChatBotId('');
return;
}
if (!selectedChatBotId || !assignedBots.some((bot) => bot.id === selectedChatBotId)) {
setSelectedChatBotId(assignedBots[0].id);
}
}, [assignedBots, selectedChatBotId]);
useEffect(() => {
setSettingsOpen(false);
}, [route.kind]);
useEffect(() => {
if (
route.kind !== 'general-chat'
&& route.kind !== 'admin-profile'
&& route.kind !== 'profile-usage-logs'
&& route.kind !== 'profile-api-tokens'
) {
navigateTo('/chat');
}
}, [route.kind]);
const mainContent = useMemo(() => {
switch (route.kind) {
case 'admin-profile':
return (
<UserProfilePage
isZh={isZh}
authBootstrap={authBootstrap}
onUpdated={onAuthBootstrapChange}
onLogout={onLogout}
/>
);
case 'profile-usage-logs':
return (
<AdminAccessPlaceholderPage
title={isZh ? '使用日志' : 'Usage Logs'}
subtitle={isZh ? '这里将聚合当前用户的 Bot 使用明细、调用轨迹与消耗记录。' : 'This page will aggregate the current users bot usage details and usage history.'}
items={[
{
label: isZh ? '调用记录' : 'Request Logs',
description: isZh ? '展示按时间、Bot 和模型维度的个人使用记录。' : 'Show personal request logs by time, bot, and model.',
},
{
label: isZh ? '会话轨迹' : 'Session Timeline',
description: isZh ? '汇总近期对话、运行事件和操作痕迹。' : 'Summarize recent chat, runtime events, and operation traces.',
},
]}
/>
);
case 'profile-api-tokens':
return (
<AdminAccessPlaceholderPage
title={isZh ? 'API 令牌' : 'API Tokens'}
subtitle={isZh ? '这里将提供个人 API 令牌的申请、轮换和吊销能力。' : 'This page will manage personal API token issuance, rotation, and revocation.'}
items={[
{
label: isZh ? '令牌列表' : 'Token List',
description: isZh ? '查看当前用户已创建的访问令牌。' : 'View access tokens created for the current user.',
},
{
label: isZh ? '轮换策略' : 'Rotation Policy',
description: isZh ? '后续接入过期控制、权限范围和调用限制。' : 'Later this will support expiration control, scopes, and rate policies.',
},
]}
/>
);
case 'general-chat':
default:
return (
<ChatWorkspacePage
authBootstrap={authBootstrap}
isZh={isZh}
compactMode
initialCompactPanelTab={mobilePrimaryTab}
hideCompactFab
selectedBotId={selectedChatBotId}
onSelectedBotChange={setSelectedChatBotId}
hideRail
/>
);
}
}, [authBootstrap, isZh, mobilePrimaryTab, onAuthBootstrapChange, onLogout, route.kind, selectedChatBotId]);
const activeNav = route.kind === 'admin-profile'
? 'profile'
: route.kind === 'profile-usage-logs' || route.kind === 'profile-api-tokens'
? 'settings'
: mobilePrimaryTab === 'runtime'
? 'panel'
: 'chat';
return (
<div className="app-shell app-shell-compact mobile-user-shell" data-theme={theme}>
<div className="mobile-user-frame">
<header className="mobile-user-header">
<div className="mobile-user-brand">
<img src="/app-bot-icon.svg" alt="Nanobot" className="mobile-user-brand-logo" />
<strong>Nanobot</strong>
</div>
<div className="mobile-user-header-actions">
<CompactHeaderSwitches />
<BotSwitcherTrigger
authBootstrap={authBootstrap}
isZh={isZh}
selectedBotId={selectedChatBotId}
onSelectBot={(botId) => {
setSelectedChatBotId(botId);
navigateTo('/chat');
}}
className="mobile-user-header-bot-switcher"
/>
</div>
</header>
<main className="mobile-user-content">
{mainContent}
</main>
<nav className="mobile-user-bottom-nav" aria-label={isZh ? '移动底部导航' : 'Mobile bottom navigation'}>
<button
type="button"
className={`mobile-user-bottom-item ${activeNav === 'chat' ? 'is-active' : ''}`}
onClick={() => {
setMobilePrimaryTab('chat');
navigateTo('/chat');
}}
>
<MessageCircle size={18} />
<span>{isZh ? 'Chat' : 'Chat'}</span>
</button>
<button
type="button"
className={`mobile-user-bottom-item ${activeNav === 'panel' ? 'is-active' : ''}`}
onClick={() => {
setMobilePrimaryTab('runtime');
navigateTo('/chat');
}}
>
<LayoutDashboard size={18} />
<span>{isZh ? '面板' : 'Panel'}</span>
</button>
<div className="mobile-user-bottom-item-wrap">
<button
type="button"
className={`mobile-user-bottom-item ${activeNav === 'settings' || settingsOpen ? 'is-active' : ''}`}
onClick={() => setSettingsOpen((value) => !value)}
>
<Settings2 size={18} />
<span>{isZh ? '设置' : 'Settings'}</span>
</button>
{settingsOpen ? (
<div className="mobile-user-settings-popover">
<button
type="button"
className="mobile-user-settings-item"
onClick={() => {
setSettingsOpen(false);
navigateTo('/profile/usage-logs');
}}
>
<History size={15} />
<span>{isZh ? '使用日志' : 'Usage Logs'}</span>
</button>
<button
type="button"
className="mobile-user-settings-item"
onClick={() => {
setSettingsOpen(false);
navigateTo('/profile/api-tokens');
}}
>
<KeyRound size={15} />
<span>{isZh ? 'API 令牌' : 'API Tokens'}</span>
</button>
</div>
) : null}
</div>
<button
type="button"
className={`mobile-user-bottom-item ${activeNav === 'profile' ? 'is-active' : ''}`}
onClick={() => navigateTo('/admin/profile')}
>
<UserRound size={18} />
<span>{isZh ? '用户设置' : 'Profile'}</span>
</button>
</nav>
</div>
</div>
);
}
type AuthenticatedDashboardAppProps = {
route: AppRoute;
authBootstrap: SysAuthBootstrap;
compactMode: boolean;
sidebarOpen: boolean;
setSidebarOpen: (value: boolean) => void;
routeMeta: AppRouteMeta;
onLogout: () => void;
onAuthBootstrapChange: (value: SysAuthBootstrap) => void;
};
export function AuthenticatedDashboardApp({
route,
authBootstrap,
compactMode,
sidebarOpen,
setSidebarOpen,
routeMeta,
onLogout,
onAuthBootstrapChange,
}: AuthenticatedDashboardAppProps) {
const { theme, locale } = useAppStore();
const isZh = locale === 'zh';
useBotsSync();
const authorizedMenuKeys = useMemo(() => collectMenuKeys(authBootstrap.menus || []), [authBootstrap.menus]);
const isNormalUser = useMemo(() => isNormalUserRole(authBootstrap), [authBootstrap]);
const assignedBots = useResolvedAssignedBots(authBootstrap);
const [selectedChatBotId, setSelectedChatBotId] = useState('');
const headerMeta = useMemo(() => {
if (route.kind !== 'general-chat') return routeMeta;
const selectedChatBot = assignedBots.find((bot) => bot.id === selectedChatBotId) || assignedBots[0];
return {
...routeMeta,
title: String(selectedChatBot?.name || 'Bot Name'),
subtitle: String(selectedChatBot?.id || 'Bot ID'),
};
}, [assignedBots, route.kind, routeMeta, selectedChatBotId]);
const handleSidebarNavigate = (path: string) => {
setSidebarOpen(false);
navigateTo(path);
};
const openProfile = () => handleSidebarNavigate('/admin/profile');
useEffect(() => {
if (!assignedBots.length) {
setSelectedChatBotId('');
return;
}
if (!selectedChatBotId || !assignedBots.some((bot) => bot.id === selectedChatBotId)) {
setSelectedChatBotId(assignedBots[0].id);
}
}, [assignedBots, selectedChatBotId]);
useEffect(() => {
if (!routeMeta.activeMenuKey) return;
if (authorizedMenuKeys.has(routeMeta.activeMenuKey)) return;
const fallbackPath = String(authBootstrap.home_path || '/dashboard').trim() || '/dashboard';
if (typeof window !== 'undefined' && window.location.pathname !== fallbackPath) {
navigateTo(fallbackPath);
}
}, [authBootstrap.home_path, authorizedMenuKeys, routeMeta.activeMenuKey]);
const mainContent: ReactNode = useMemo(() => {
switch (route.kind) {
case 'dashboard':
return <PlatformHomePage isZh={isZh} onNavigate={navigateTo} />;
case 'general-chat':
return (
<ChatWorkspacePage
authBootstrap={authBootstrap}
isZh={isZh}
compactMode={compactMode}
selectedBotId={selectedChatBotId}
onSelectedBotChange={setSelectedChatBotId}
hideRail={isNormalUser}
/>
);
case 'dashboard-edges':
return <NodeHomePage compactMode={compactMode} />;
case 'dashboard-node':
return <NodeWorkspacePage nodeId={route.nodeId} compactMode={compactMode} />;
case 'admin-skills':
return <SkillMarketManagerPage isZh={isZh} />;
case 'admin-profile':
return (
<UserProfilePage
isZh={isZh}
authBootstrap={authBootstrap}
onUpdated={onAuthBootstrapChange}
onLogout={onLogout}
/>
);
case 'admin-settings':
return <PlatformSettingsPage isZh={isZh} />;
case 'admin-templates':
return <TemplateManagerPage isZh={isZh} />;
case 'admin-deploy':
return <PlatformDashboardPage compactMode={compactMode} />;
case 'admin-users':
return <UserManagementPage isZh={isZh} />;
case 'admin-roles':
return <RoleManagementPage isZh={isZh} />;
case 'profile-usage-logs':
return (
<AdminAccessPlaceholderPage
title={isZh ? '使用日志' : 'Usage Logs'}
subtitle={isZh ? '这里将聚合当前用户的 Bot 使用明细、调用轨迹与消耗记录。' : 'This page will aggregate the current users bot usage details and usage history.'}
items={[
{
label: isZh ? '调用记录' : 'Request Logs',
description: isZh ? '展示按时间、Bot 和模型维度的个人使用记录。' : 'Show personal request logs by time, bot, and model.',
},
{
label: isZh ? '会话轨迹' : 'Session Timeline',
description: isZh ? '汇总近期对话、运行事件和操作痕迹。' : 'Summarize recent chat, runtime events, and operation traces.',
},
]}
/>
);
case 'profile-api-tokens':
return (
<AdminAccessPlaceholderPage
title={isZh ? 'API 令牌' : 'API Tokens'}
subtitle={isZh ? '这里将提供个人 API 令牌的申请、轮换和吊销能力。' : 'This page will manage personal API token issuance, rotation, and revocation.'}
items={[
{
label: isZh ? '令牌列表' : 'Token List',
description: isZh ? '查看当前用户已创建的访问令牌。' : 'View access tokens created for the current user.',
},
{
label: isZh ? '轮换策略' : 'Rotation Policy',
description: isZh ? '后续接入过期控制、权限范围和调用限制。' : 'Later this will support expiration control, scopes, and rate policies.',
},
]}
/>
);
case 'bot':
return <BotHomePage botId={route.botId} compactMode={compactMode} />;
}
}, [authBootstrap, compactMode, isNormalUser, isZh, onAuthBootstrapChange, onLogout, route, selectedChatBotId]);
if (compactMode && isNormalUser) {
return (
<PersonalMobileShell
route={route}
authBootstrap={authBootstrap}
onLogout={onLogout}
onAuthBootstrapChange={onAuthBootstrapChange}
/>
);
}
return (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className="sys-shell">
{compactMode ? (
<>
<div className={`sys-sidebar-backdrop ${sidebarOpen ? 'is-open' : ''}`} onClick={() => setSidebarOpen(false)} />
<aside className={`sys-sidebar sys-sidebar-drawer ${sidebarOpen ? 'is-open' : ''}`}>
<div className="sys-sidebar-header">
<button className="btn btn-secondary btn-sm icon-btn" type="button" onClick={() => setSidebarOpen(false)}>
<X size={14} />
</button>
</div>
<div className="sys-brand">
<img src="/app-bot-icon.svg" alt="Nanobot" className="sys-brand-logo" />
<div>
<strong>Nanobot</strong>
<span>{isZh ? '控制台' : 'Console'}</span>
</div>
</div>
<SidebarMenu menus={authBootstrap.menus || []} activeMenuKey={routeMeta.activeMenuKey} onNavigate={handleSidebarNavigate} />
<div className="sys-sidebar-footer">
<SidebarAccountPill authBootstrap={authBootstrap} isZh={isZh} onOpenProfile={openProfile} onLogout={onLogout} />
</div>
</aside>
</>
) : (
<aside className="sys-sidebar">
<div className="sys-brand">
<img src="/app-bot-icon.svg" alt="Nanobot" className="sys-brand-logo" />
<div>
<strong>Nanobot</strong>
<span>{isZh ? '控制台' : 'Console'}</span>
</div>
</div>
<SidebarMenu menus={authBootstrap.menus || []} activeMenuKey={routeMeta.activeMenuKey} onNavigate={handleSidebarNavigate} />
<div className="sys-sidebar-footer">
<SidebarAccountPill authBootstrap={authBootstrap} isZh={isZh} onOpenProfile={openProfile} onLogout={onLogout} />
</div>
</aside>
)}
<div className="sys-main">
<header className="app-header sys-topbar">
<div className="row-between app-header-top">
<div className="sys-topbar-title">
{compactMode ? (
<button className="btn btn-secondary btn-sm icon-btn" type="button" onClick={() => setSidebarOpen(true)}>
<Menu size={16} />
</button>
) : null}
<div>
<h1>{headerMeta.title}</h1>
<div className="app-route-subtitle">{headerMeta.subtitle}</div>
</div>
</div>
<div className="sys-topbar-actions">
<ThemeLocaleSwitches />
{isNormalUser ? (
<BotSwitcherTrigger
authBootstrap={authBootstrap}
isZh={isZh}
selectedBotId={selectedChatBotId}
onSelectBot={(botId) => {
setSelectedChatBotId(botId);
navigateTo('/chat');
}}
className="sys-topbar-bot-switcher"
/>
) : null}
</div>
</div>
</header>
<main className={`main-stage sys-main-stage ${route.kind === 'general-chat' || route.kind === 'bot' ? 'is-chat-route' : ''}`}>
{mainContent}
</main>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,115 @@
import type { SysMenuItem } from '../types/sys';
import type { AppRoute } from '../utils/appRoute';
export type AppRouteMeta = {
title: string;
subtitle: string;
activeMenuKey: string;
};
export function navigateTo(path: string) {
if (typeof window === 'undefined') return;
window.history.pushState({}, '', path);
window.dispatchEvent(new PopStateEvent('popstate'));
}
export function getRouteMeta(route: AppRoute, isZh: boolean): AppRouteMeta {
switch (route.kind) {
case 'dashboard':
return {
title: isZh ? 'Dashboard' : 'Dashboard',
subtitle: isZh ? '平台总览与快捷入口' : 'Platform overview and quick access',
activeMenuKey: 'general_dashboard',
};
case 'general-chat':
return {
title: 'Bot Name',
subtitle: 'Bot ID',
activeMenuKey: 'general_chat',
};
case 'dashboard-edges':
return {
title: isZh ? 'Edge 管理' : 'Edge Management',
subtitle: isZh ? '管理节点连通性、能力与运行状态' : 'Manage node connectivity, capabilities, and runtime state',
activeMenuKey: 'general_edge',
};
case 'dashboard-node':
return {
title: isZh ? `节点工作区 · ${route.nodeId}` : `Node Workspace · ${route.nodeId}`,
subtitle: isZh ? '查看当前节点资源与 Bot 列表' : 'Inspect node resources and bot list',
activeMenuKey: 'general_edge',
};
case 'admin-skills':
return {
title: isZh ? '技能管理' : 'Skill Management',
subtitle: isZh ? '维护技能市场 ZIP 包与元数据' : 'Maintain marketplace ZIP packages and metadata',
activeMenuKey: 'admin_skills',
};
case 'admin-profile':
return {
title: isZh ? '用户设置' : 'User Settings',
subtitle: isZh ? '维护当前登录账号的资料与密码' : 'Manage the current account profile and password',
activeMenuKey: '',
};
case 'admin-settings':
return {
title: isZh ? '系统参数' : 'System Settings',
subtitle: isZh ? '维护平台运行参数与前端可见设置' : 'Maintain runtime settings and public configuration',
activeMenuKey: 'admin_settings',
};
case 'admin-templates':
return {
title: isZh ? '模版管理' : 'Template Management',
subtitle: isZh ? '维护平台级模版、规则与 Topic 预设' : 'Maintain platform templates, rules, and topic presets',
activeMenuKey: 'admin_templates',
};
case 'admin-deploy':
return {
title: isZh ? '迁移 / 部署' : 'Migration / Deploy',
subtitle: isZh ? '集中处理 Bot 创建、镜像与部署工作流' : 'Handle bot creation, images, and deployment workflows',
activeMenuKey: 'admin_deploy',
};
case 'admin-users':
return {
title: isZh ? '用户管理' : 'User Management',
subtitle: isZh ? '管理登录用户、启用状态与基础资料' : 'Manage users, status, and profile basics',
activeMenuKey: 'admin_users',
};
case 'admin-roles':
return {
title: isZh ? '角色管理' : 'Role Management',
subtitle: isZh ? '管理角色以及菜单、权限分配' : 'Manage roles and their menu / permission grants',
activeMenuKey: 'admin_roles',
};
case 'profile-usage-logs':
return {
title: isZh ? '使用日志' : 'Usage Logs',
subtitle: isZh ? '个人使用日志入口占位,后续将接入明细能力' : 'Placeholder for personal usage logs',
activeMenuKey: 'profile_usage_logs',
};
case 'profile-api-tokens':
return {
title: isZh ? 'API 令牌' : 'API Tokens',
subtitle: isZh ? '个人 API 令牌入口占位,后续将接入管理能力' : 'Placeholder for personal API token management',
activeMenuKey: 'profile_api_tokens',
};
case 'bot':
return {
title: isZh ? 'Bot 工作台' : 'Bot Workspace',
subtitle: isZh ? '单个 Bot 的聊天与运行面板' : 'Chat and runtime panel for a single bot',
activeMenuKey: '',
};
}
}
export function collectMenuKeys(menus: SysMenuItem[]): Set<string> {
const keys = new Set<string>();
const visit = (items: SysMenuItem[]) => {
items.forEach((item) => {
if (item.menu_key) keys.add(item.menu_key);
if (Array.isArray(item.children) && item.children.length > 0) visit(item.children);
});
};
visit(menus || []);
return keys;
}

View File

@ -0,0 +1,98 @@
import { useEffect, type CSSProperties, type ReactNode } from 'react';
import { Maximize2, Minimize2, X } from 'lucide-react';
import { LucentIconButton } from './LucentIconButton';
export type LucentDrawerSize = 'default' | 'expand';
interface LucentDrawerProps {
open: boolean;
title: string;
description?: string;
size?: LucentDrawerSize;
onClose: () => void;
onToggleSize?: () => void;
children: ReactNode;
footer?: ReactNode;
headerActions?: ReactNode;
topOffset?: number;
panelClassName?: string;
bodyClassName?: string;
closeLabel?: string;
expandLabel?: string;
collapseLabel?: string;
}
export function LucentDrawer({
open,
title,
description,
size = 'default',
onClose,
onToggleSize,
children,
footer,
headerActions,
topOffset = 0,
panelClassName = '',
bodyClassName = '',
closeLabel = 'Close panel',
expandLabel = 'Expand panel',
collapseLabel = 'Collapse panel',
}: LucentDrawerProps) {
useEffect(() => {
if (!open) return;
const handleKeyDown = (event: KeyboardEvent) => {
if (event.key === 'Escape') onClose();
};
document.addEventListener('keydown', handleKeyDown);
return () => document.removeEventListener('keydown', handleKeyDown);
}, [onClose, open]);
return (
<>
<div
className={`lucent-drawer-mask ${open ? 'is-open' : ''} ${size === 'expand' ? 'is-expand' : ''}`}
onClick={open ? onClose : undefined}
aria-hidden="true"
/>
<aside
className={`lucent-drawer ${open ? 'is-open' : ''} is-${size}`}
style={{ '--lucent-drawer-top': `${topOffset}px` } as CSSProperties}
aria-hidden={!open}
>
<div className={`lucent-drawer-panel card ${panelClassName}`.trim()} onClick={(event) => event.stopPropagation()}>
<div className="lucent-drawer-header">
<div className="lucent-drawer-header-copy">
<div className="section-mini-title">{title}</div>
{description ? <div className="field-label">{description}</div> : null}
</div>
<div className="lucent-drawer-header-actions">
{headerActions}
{onToggleSize ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleSize}
tooltip={size === 'expand' ? collapseLabel : expandLabel}
aria-label={size === 'expand' ? collapseLabel : expandLabel}
>
{size === 'expand' ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</LucentIconButton>
) : null}
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onClose}
tooltip={closeLabel}
aria-label={closeLabel}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div className={`lucent-drawer-body ${bodyClassName}`.trim()}>{children}</div>
{footer ? <div className="lucent-drawer-footer">{footer}</div> : null}
</div>
</aside>
</>
);
}

View File

@ -7,7 +7,7 @@ import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgr
import { pickLocale } from '../i18n';
import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn';
import { botsSyncEn } from '../i18n/bots-sync.en';
import { buildMonitorWsUrl } from '../utils/botAccess';
import { buildMonitorWsUrl, isBotUnauthorizedError } from '../utils/botAccess';
function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' {
const s = (v || '').toUpperCase();
@ -84,6 +84,7 @@ export function useBotsSync(forcedBotId?: string) {
const lastAssistantRef = useRef<Record<string, { text: string; ts: number }>>({});
const lastProgressRef = useRef<Record<string, { text: string; ts: number }>>({});
const hydratedMessagesRef = useRef<Record<string, boolean>>({});
const unauthorizedBotsRef = useRef<Record<string, boolean>>({});
const isZh = useAppStore((s) => s.locale === 'zh');
const locale = useAppStore((s) => s.locale);
const t = pickLocale(locale, { 'zh-cn': botsSyncZhCn, en: botsSyncEn });
@ -97,6 +98,7 @@ export function useBotsSync(forcedBotId?: string) {
async (botId: string) => {
const target = String(botId || '').trim();
if (!target) return;
if (unauthorizedBotsRef.current[target]) return;
try {
const res = await axios.get<{ items?: any[] }>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`);
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
@ -136,6 +138,10 @@ export function useBotsSync(forcedBotId?: string) {
const lastAssistant = [...messages].reverse().find((m) => m.role === 'assistant');
if (lastAssistant) lastAssistantRef.current[target] = { text: lastAssistant.text, ts: lastAssistant.ts };
} catch (error) {
if (isBotUnauthorizedError(error, target)) {
unauthorizedBotsRef.current[target] = true;
return;
}
console.error(`Failed to sync bot messages for ${target}`, error);
}
},
@ -147,10 +153,15 @@ export function useBotsSync(forcedBotId?: string) {
try {
if (forced) {
const res = await axios.get<BotState>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forced)}`);
delete unauthorizedBotsRef.current[forced];
setBots(res.data ? [res.data] : []);
return;
}
const res = await axios.get<BotState[]>(`${APP_ENDPOINTS.apiBase}/bots`);
(Array.isArray(res.data) ? res.data : []).forEach((bot) => {
const botId = String(bot?.id || '').trim();
if (botId) delete unauthorizedBotsRef.current[botId];
});
setBots(res.data);
} catch (error) {
console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error);
@ -171,6 +182,7 @@ export function useBotsSync(forcedBotId?: string) {
Object.keys(hydratedMessagesRef.current).forEach((botId) => {
if (!aliveIds.has(botId)) {
delete hydratedMessagesRef.current[botId];
delete unauthorizedBotsRef.current[botId];
}
});
@ -204,6 +216,7 @@ export function useBotsSync(forcedBotId?: string) {
useEffect(() => {
const runningIds = new Set(
Object.values(activeBots)
.filter((bot) => !unauthorizedBotsRef.current[bot.id])
.filter((bot) => bot.docker_status === 'RUNNING')
.map((bot) => bot.id),
);

View File

@ -71,9 +71,9 @@ export const dashboardEn = {
nodeGroupCount: (count: number) => `${count} bot${count === 1 ? '' : 's'}`,
botSearchPlaceholder: 'Search by bot name or ID',
botSearchNoResult: 'No matching bots.',
workspaceSearchPlaceholder: 'Search by file name or path',
workspaceSearchPlaceholder: 'Search workspace files',
workspaceSearchNoResult: 'No matching files or folders.',
searchAction: 'Search',
searchAction: 'Search workspace files',
clearSearch: 'Clear search',
syncingPageSize: 'Syncing page size...',
paginationPrev: 'Prev',
@ -125,6 +125,8 @@ export const dashboardEn = {
clearHistoryFail: 'Failed to clear conversation history.',
exportHistory: 'Export JSON',
exportHistoryFail: 'Failed to export conversation.',
quickActions: 'Quick Actions',
resourceMonitor: 'Resource Monitor',
you: 'You',
user: 'User',
inputPlaceholder: 'Type a command and press Enter (Shift+Enter for newline)',

View File

@ -71,10 +71,10 @@ export const dashboardZhCn = {
nodeGroupCount: (count: number) => `${count} 个 Bot`,
botSearchPlaceholder: '按 Bot 名称或 ID 搜索',
botSearchNoResult: '没有匹配的 Bot。',
workspaceSearchPlaceholder: '按文件名或路径搜索',
workspaceSearchPlaceholder: '搜索工作区文件',
workspaceSearchNoResult: '没有匹配的文件或目录。',
searchAction: '搜索',
clearSearch: '清搜索',
searchAction: '搜索工作区文件',
clearSearch: '清搜索',
syncingPageSize: '正在同步分页设置...',
paginationPrev: '上一页',
paginationNext: '下一页',
@ -125,6 +125,8 @@ export const dashboardZhCn = {
clearHistoryFail: '清空对话历史失败。',
exportHistory: '导出对话',
exportHistoryFail: '导出对话失败。',
quickActions: '快捷操作',
resourceMonitor: '资源监控',
you: '你',
user: '用户',
inputPlaceholder: '输入指令后回车发送Shift+Enter 换行)',

View File

@ -1,6 +1,6 @@
export const wizardEn = {
title: 'Guided Bot Creation',
sub: 'Select image first, then configure model and agent files.',
title: '',
sub: '',
s1: '1. Select READY image',
s2: '2. Model and params',
s3: '3. Agent files',

View File

@ -1,6 +1,6 @@
export const wizardZhCn = {
title: '引导式 Bot 创建',
sub: '先选镜像,再配置模型与代理文件。',
title: '',
sub: '',
s1: '1. 选择 READY 镜像',
s2: '2. 模型与参数',
s3: '3. 代理文件配置',

View File

@ -4,9 +4,9 @@ import './index.css'
import App from './App.tsx'
import { LucentPromptProvider } from './components/lucent/LucentPromptProvider.tsx'
import { setupBotAccessAuth } from './utils/botAccess.ts'
import { setupPanelAccessAuth } from './utils/panelAccess.ts'
import { setupSessionAuth } from './utils/sessionAuth.ts'
setupPanelAccessAuth();
setupSessionAuth();
setupBotAccessAuth();
createRoot(document.getElementById('root')!).render(

View File

@ -3,8 +3,17 @@ import { BotDashboardModule } from '../dashboard/BotDashboardModule';
interface BotHomePageProps {
botId: string;
compactMode: boolean;
initialCompactPanelTab?: 'chat' | 'runtime';
hideCompactFab?: boolean;
}
export function BotHomePage({ botId, compactMode }: BotHomePageProps) {
return <BotDashboardModule forcedBotId={botId} compactMode={compactMode} />;
export function BotHomePage({ botId, compactMode, initialCompactPanelTab, hideCompactFab }: BotHomePageProps) {
return (
<BotDashboardModule
forcedBotId={botId}
compactMode={compactMode}
initialCompactPanelTab={initialCompactPanelTab}
hideCompactFab={hideCompactFab}
/>
);
}

View File

@ -0,0 +1,188 @@
import { useEffect, useMemo, useState } from 'react';
import { ChevronRight, ChevronsLeftRight, MessageCircle } from 'lucide-react';
import { BotHomePage } from '../bot-home/BotHomePage';
import { useAppStore } from '../../store/appStore';
import type { BotState } from '../../types/bot';
import type { SysAuthBootstrap } from '../../types/sys';
interface ChatWorkspacePageProps {
authBootstrap: SysAuthBootstrap;
isZh: boolean;
compactMode: boolean;
initialCompactPanelTab?: 'chat' | 'runtime';
hideCompactFab?: boolean;
selectedBotId?: string;
onSelectedBotChange?: (botId: string) => void;
hideRail?: boolean;
}
function normalizeStatusTone(bot?: BotState) {
if (!bot) return 'is-stopped';
if (bot.enabled === false) return 'is-disabled';
if (String(bot.docker_status || '').toUpperCase() === 'RUNNING') return 'is-running';
return 'is-stopped';
}
export function ChatWorkspacePage({
authBootstrap,
isZh,
compactMode,
initialCompactPanelTab,
hideCompactFab,
selectedBotId: controlledSelectedBotId,
onSelectedBotChange,
hideRail = false,
}: ChatWorkspacePageProps) {
const activeBots = useAppStore((state) => state.activeBots);
const [internalSelectedBotId, setInternalSelectedBotId] = useState('');
const [railExpanded, setRailExpanded] = useState(false);
const orderedBots = useMemo(() => {
const assigned = Array.isArray(authBootstrap.assigned_bots) ? authBootstrap.assigned_bots : [];
if (assigned.length > 0) {
const liveBotIds = new Set(Object.keys(activeBots).filter((botId) => String(botId || '').trim()));
const preferLiveIntersection = liveBotIds.size > 0;
return assigned
.filter((item) => !preferLiveIntersection || liveBotIds.has(String(item.id || '').trim()))
.map((item) => {
const live = activeBots[item.id];
return live
? { ...live, node_display_name: live.node_display_name || item.node_display_name || item.node_id }
: ({
id: item.id,
name: item.name,
enabled: item.enabled,
docker_status: item.docker_status,
node_id: item.node_id,
node_display_name: item.node_display_name,
image_tag: item.image_tag,
avatar_model: 'base',
logs: [],
} as BotState);
})
.filter((bot) => String(bot.id || '').trim());
}
return Object.values(activeBots).sort((left, right) => String(left.name || left.id).localeCompare(String(right.name || right.id)));
}, [activeBots, authBootstrap.assigned_bots]);
useEffect(() => {
if (!orderedBots.length) {
setInternalSelectedBotId('');
return;
}
const targetId = String(controlledSelectedBotId || internalSelectedBotId || '').trim();
if (!targetId || !orderedBots.some((bot) => bot.id === targetId)) {
const fallbackId = orderedBots[0]?.id || '';
setInternalSelectedBotId(fallbackId);
onSelectedBotChange?.(fallbackId);
}
}, [controlledSelectedBotId, internalSelectedBotId, onSelectedBotChange, orderedBots]);
useEffect(() => {
if (orderedBots.length <= 1) {
setRailExpanded(false);
}
}, [orderedBots.length]);
const selectedBotId = String(controlledSelectedBotId || internalSelectedBotId || '').trim();
const selectedBot = orderedBots.find((bot) => bot.id === selectedBotId) || orderedBots[0];
const selectedTone = normalizeStatusTone(selectedBot);
const selectedShortName = String(selectedBot?.name || selectedBot?.id || '')
.slice(0, 1)
.toUpperCase() || 'B';
if (!orderedBots.length) {
return (
<div className="platform-page-stack">
<section className="panel stack chat-workspace-empty-panel">
<div className="ops-empty-inline">
{isZh ? '当前账号还没有分配可用的 Bot。' : 'No bots are assigned to this account yet.'}
</div>
</section>
</div>
);
}
return (
<div className={`chat-workspace-page ${hideRail ? 'is-rail-hidden' : ''}`}>
{!hideRail ? (
<aside className="chat-workspace-rail">
<div className="chat-workspace-rail-stack">
<button
type="button"
className={`chat-workspace-rail-tab ${selectedTone} is-active`}
onClick={() => {
if (orderedBots.length > 1) {
setRailExpanded((value) => !value);
}
}}
title={selectedBot?.name || selectedBot?.id}
aria-label={selectedBot?.name || selectedBot?.id || (isZh ? '当前 Bot' : 'Current bot')}
>
<span>{selectedShortName}</span>
</button>
{orderedBots.length > 1 ? (
<button
type="button"
className={`chat-workspace-rail-toggle ${railExpanded ? 'is-active' : ''}`}
onClick={() => setRailExpanded((value) => !value)}
title={isZh ? '展开 Bot 切换' : 'Expand bot switcher'}
aria-label={isZh ? '展开 Bot 切换' : 'Expand bot switcher'}
>
{railExpanded ? <ChevronRight size={14} /> : <ChevronsLeftRight size={14} />}
</button>
) : null}
</div>
{railExpanded ? (
<div className="chat-workspace-popout">
<div className="chat-workspace-popout-head">
<strong>{isZh ? '切换 Bot' : 'Switch Bot'}</strong>
<span>{isZh ? `${orderedBots.length}` : `${orderedBots.length}`}</span>
</div>
<div className="chat-workspace-popout-list">
{orderedBots.map((bot) => {
const selected = bot.id === selectedBotId;
return (
<button
key={bot.id}
type="button"
className={`chat-workspace-popout-item ${selected ? 'is-active' : ''}`}
onClick={() => {
setInternalSelectedBotId(bot.id);
onSelectedBotChange?.(bot.id);
setRailExpanded(false);
}}
>
<strong>{bot.name || bot.id}</strong>
<span className="mono">{bot.id}</span>
</button>
);
})}
</div>
</div>
) : null}
</aside>
) : null}
<section className={`chat-workspace-main chat-workspace-main-attached ${compactMode ? 'is-compact' : ''}`}>
{selectedBotId ? (
<BotHomePage
botId={selectedBotId}
compactMode={compactMode}
initialCompactPanelTab={initialCompactPanelTab}
hideCompactFab={hideCompactFab}
/>
) : (
<div className="panel stack chat-workspace-empty-panel">
<div className="ops-empty-inline">
<MessageCircle size={16} />
<span>{isZh ? '从左侧选择一个 Bot 开始使用。' : 'Select a bot from the left to start.'}</span>
</div>
</div>
)}
</section>
</div>
);
}

View File

@ -144,6 +144,11 @@
.ops-search-input {
min-width: 0;
height: 36px;
box-sizing: border-box;
padding-top: 0;
padding-bottom: 0;
line-height: 1.2;
}
.ops-search-input-with-icon {
@ -604,7 +609,7 @@
position: fixed;
left: 12px;
right: 12px;
top: 58px;
top: 12px;
bottom: 12px;
z-index: 72;
animation: ops-compact-sheet-in 220ms ease;
@ -612,7 +617,7 @@
.ops-compact-close-btn {
position: fixed;
top: 18px;
top: 16px;
right: 14px;
width: 34px;
height: 34px;
@ -2068,6 +2073,80 @@
gap: 8px;
}
.ops-runtime-shortcuts {
display: grid;
grid-template-columns: 1fr;
gap: 10px;
}
.ops-runtime-shortcut {
width: 100%;
border: 1px solid color-mix(in oklab, var(--line) 74%, transparent);
border-radius: 14px;
background: color-mix(in oklab, var(--panel-soft) 78%, transparent);
color: var(--text);
min-height: 64px;
padding: 12px 14px;
display: flex;
align-items: center;
gap: 12px;
text-align: left;
cursor: pointer;
transition: border-color 0.18s ease, background 0.18s ease, transform 0.18s ease;
}
.ops-runtime-shortcut:hover {
border-color: color-mix(in oklab, var(--brand) 42%, var(--line) 58%);
background: color-mix(in oklab, var(--brand-soft) 16%, var(--panel-soft) 84%);
transform: translateY(var(--hover-lift-y));
box-shadow: var(--hover-shadow-sm);
}
.ops-runtime-shortcut:disabled {
opacity: 0.55;
cursor: not-allowed;
transform: none;
}
.ops-runtime-shortcut.is-danger:hover {
border-color: rgba(215, 102, 102, 0.34);
background: rgba(215, 102, 102, 0.12);
}
.ops-runtime-shortcut-icon {
width: 38px;
height: 38px;
border-radius: 12px;
display: inline-flex;
align-items: center;
justify-content: center;
background: color-mix(in oklab, var(--brand-soft) 60%, var(--panel) 40%);
color: var(--title);
flex: 0 0 auto;
}
.ops-runtime-shortcut.is-danger .ops-runtime-shortcut-icon {
background: rgba(215, 102, 102, 0.16);
color: #d76666;
}
.ops-runtime-shortcut-copy {
min-width: 0;
display: grid;
gap: 4px;
}
.ops-runtime-shortcut-copy strong {
color: var(--title);
font-size: 13px;
}
.ops-runtime-shortcut-copy span {
color: var(--muted);
font-size: 12px;
line-height: 1.45;
}
.ops-topic-feed {
position: relative;
display: grid;
@ -2730,10 +2809,43 @@
overscroll-behavior: contain;
}
.ops-config-modal {
min-height: clamp(480px, 68vh, 760px);
.ops-bot-panel-drawer-body {
min-height: 0;
padding-top: 16px;
overscroll-behavior: contain;
}
.ops-bot-panel-drawer-shell {
display: flex;
flex-direction: column;
gap: 10px;
min-height: 0;
}
.ops-config-modal {
min-height: 0;
display: flex;
flex-direction: column;
}
.ops-bot-panel-drawer-footer {
width: 100%;
gap: 12px;
}
.lucent-drawer-footer .ops-skill-add-bar {
width: 100%;
padding-top: 0;
border-top: 0;
}
.ops-bot-panel-drawer-body .ops-skills-list-scroll,
.ops-bot-panel-drawer-body .ops-config-list-scroll,
.ops-bot-panel-drawer-body .ops-cron-list-scroll {
min-height: 0;
max-height: none;
overflow: visible;
padding-right: 0;
}
.ops-config-list-scroll {
@ -2825,10 +2937,9 @@
}
.ops-config-footer {
position: sticky;
bottom: 0;
background: var(--panel);
border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent);
position: static;
background: transparent;
border-top: 0;
padding-top: 8px;
}
@ -2898,6 +3009,14 @@
min-height: clamp(420px, 62vh, 640px);
}
.ops-bot-panel-drawer-body {
padding: 14px 16px 18px;
}
.ops-bot-panel-drawer-body .row-between {
gap: 10px;
}
.ops-config-list-scroll {
min-height: 220px;
}
@ -2924,10 +3043,13 @@
justify-content: center;
}
.ops-config-footer {
position: static;
border-top: 0;
padding-top: 0;
.ops-bot-panel-drawer-footer {
align-items: stretch;
}
.ops-bot-panel-drawer-footer .ops-topic-create-menu-wrap,
.ops-bot-panel-drawer-footer .row-actions-inline {
width: 100%;
}
}

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,126 @@
import axios from 'axios';
import { APP_ENDPOINTS } from '../../../config/env';
import type { BotSkillMarketItem } from '../../platform/types';
import type {
BotChannel,
BotTopic,
CronJobsResponse,
MCPConfigResponse,
MarketSkillInstallResponse,
SkillUploadResponse,
TopicFeedListResponse,
TopicFeedStatsResponse,
WorkspaceSkillOption,
} from '../botDashboardShared';
export function getBotTopics(botId: string) {
return axios.get<BotTopic[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics`);
}
export function updateBotTopic(botId: string, topicKey: string, payload: Record<string, unknown>) {
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics/${encodeURIComponent(topicKey)}`, payload);
}
export function createBotTopic(botId: string, payload: Record<string, unknown>) {
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics`, payload);
}
export function deleteBotTopic(botId: string, topicKey: string) {
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topics/${encodeURIComponent(topicKey)}`);
}
export function getTopicFeedItems(botId: string, params: Record<string, string | number>) {
return axios.get<TopicFeedListResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items`, { params });
}
export function getTopicFeedStatsApi(botId: string) {
return axios.get<TopicFeedStatsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items/stats`);
}
export function markTopicFeedItemReadApi(botId: string, itemId: number) {
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items/${itemId}/read`);
}
export function deleteTopicFeedItemApi(botId: string, itemId: number) {
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/topic-items/${itemId}`);
}
export function getBotChannels(botId: string) {
return axios.get<BotChannel[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`);
}
export function updateBotChannel(botId: string, channelId: string | number, payload: Record<string, unknown>) {
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels/${channelId}`, payload);
}
export function createBotChannel(botId: string, payload: Record<string, unknown>) {
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels`, payload);
}
export function deleteBotChannel(botId: string, channelId: string | number) {
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/channels/${channelId}`);
}
export function getBotSkills(botId: string) {
return axios.get<WorkspaceSkillOption[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills`);
}
export function getSkillMarket(botId: string) {
return axios.get<BotSkillMarketItem[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market`);
}
export function deleteBotSkillApi(botId: string, skillId: string) {
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills/${encodeURIComponent(skillId)}`);
}
export function installMarketSkillApi(botId: string, skillId: number) {
return axios.post<MarketSkillInstallResponse>(
`${APP_ENDPOINTS.apiBase}/bots/${botId}/skill-market/${skillId}/install`,
);
}
export function uploadBotSkillZipApi(botId: string, formData: FormData) {
return axios.post<SkillUploadResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/skills/upload`, formData);
}
export function getBotEnvParams(botId: string) {
return axios.get<{ env_params?: Record<string, string> }>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`);
}
export function updateBotEnvParams(botId: string, envParams: Record<string, string>) {
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/env-params`, { env_params: envParams });
}
export function getBotMcpConfig(botId: string) {
return axios.get<MCPConfigResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`);
}
export function updateBotMcpConfig(botId: string, mcpServers: Record<string, unknown>) {
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config`, { mcp_servers: mcpServers });
}
export function getBotCronJobs(botId: string) {
return axios.get<CronJobsResponse>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs`, {
params: { include_disabled: true },
});
}
export function stopBotCronJobApi(botId: string, jobId: string) {
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs/${jobId}/stop`);
}
export function deleteBotCronJobApi(botId: string, jobId: string) {
return axios.delete(`${APP_ENDPOINTS.apiBase}/bots/${botId}/cron/jobs/${jobId}`);
}
export function updateBotGlobalDelivery(botId: string, payload: { send_progress: boolean; send_tool_hints: boolean }) {
return axios.put(`${APP_ENDPOINTS.apiBase}/bots/${botId}`, payload);
}
export function stopBotRuntimeApi(botId: string) {
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/stop`);
}
export function startBotRuntimeApi(botId: string) {
return axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/start`);
}

View File

@ -0,0 +1,26 @@
import type { ChatMessage } from '../../types/bot';
import { normalizeAttachmentPaths } from './shared/botDashboardWorkspace';
export * from './shared/botDashboardTypes';
export * from './shared/botDashboardConstants';
export * from './shared/botDashboardWorkspace';
export * from './shared/botDashboardUtils';
export * from './shared/botDashboardConversation';
export * from './shared/botDashboardTopicUtils';
export function mapBotMessageResponseRow(row: any): ChatMessage {
const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] =
roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant';
const feedbackRaw = String(row?.feedback || '').trim().toLowerCase();
const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null;
return {
id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined,
role,
text: String(row?.text || ''),
attachments: normalizeAttachmentPaths(row?.media),
ts: Number(row?.ts || Date.now()),
feedback,
kind: 'final',
} as ChatMessage;
}

View File

@ -0,0 +1,37 @@
export type BotPanelKey = 'base' | 'params' | 'channels' | 'topic' | 'env' | 'skills' | 'mcp' | 'cron' | 'agent';
export interface BotPanelDefinition {
key: BotPanelKey;
labelZh: string;
labelEn: string;
iconKey: 'settings' | 'sliders' | 'channels' | 'topic' | 'env' | 'skills' | 'mcp' | 'cron' | 'agent';
routePanel: BotPanelKey;
visibility: 'admin' | 'user' | 'both';
}
export const BOT_PANEL_DEFINITIONS: BotPanelDefinition[] = [
{ key: 'base', labelZh: '基础', labelEn: 'Basics', iconKey: 'settings', routePanel: 'base', visibility: 'both' },
{ key: 'params', labelZh: '模型', labelEn: 'Model', iconKey: 'sliders', routePanel: 'params', visibility: 'both' },
{ key: 'channels', labelZh: '渠道', labelEn: 'Channels', iconKey: 'channels', routePanel: 'channels', visibility: 'both' },
{ key: 'topic', labelZh: '主题', labelEn: 'Topic', iconKey: 'topic', routePanel: 'topic', visibility: 'both' },
{ key: 'env', labelZh: '环境变量', labelEn: 'Env', iconKey: 'env', routePanel: 'env', visibility: 'both' },
{ key: 'skills', labelZh: '技能', labelEn: 'Skills', iconKey: 'skills', routePanel: 'skills', visibility: 'both' },
{ key: 'mcp', labelZh: 'MCP', labelEn: 'MCP', iconKey: 'mcp', routePanel: 'mcp', visibility: 'both' },
{ key: 'cron', labelZh: '定时任务', labelEn: 'Cron', iconKey: 'cron', routePanel: 'cron', visibility: 'both' },
{ key: 'agent', labelZh: '代理', labelEn: 'Agent', iconKey: 'agent', routePanel: 'agent', visibility: 'both' },
];
export function normalizeDeepLinkPanel(value: string | null | undefined, entry?: string | null | undefined): BotPanelKey | null {
const source = String(entry || '').trim().toLowerCase();
if (source && source !== 'node-shortcut') return null;
const normalized = String(value || '').trim().toLowerCase();
const found = BOT_PANEL_DEFINITIONS.find((item) => item.key === normalized);
return found ? found.key : null;
}
export function buildBotPanelHref(botId: string, panel?: BotPanelKey) {
const encodedId = encodeURIComponent(String(botId || '').trim());
if (!panel) return `/bot/${encodedId}`;
const params = new URLSearchParams({ panel, entry: 'node-shortcut' });
return `/bot/${encodedId}?${params.toString()}`;
}

View File

@ -0,0 +1,273 @@
import { PasswordInput } from '../../../components/PasswordInput';
import type { channelsEn } from '../../../i18n/channels.en';
import type { channelsZhCn } from '../../../i18n/channels.zh-cn';
import type { BotChannel } from '../botDashboardShared';
import type { PasswordToggleLabels } from './BotConfigDrawerParts';
type ChannelMessages = typeof channelsZhCn | typeof channelsEn;
const parseChannelListValue = (raw: unknown): string => {
if (!Array.isArray(raw)) return '';
return raw
.map((item) => String(item || '').trim())
.filter(Boolean)
.join('\n');
};
const parseChannelListInput = (raw: string): string[] => {
const rows: string[] = [];
String(raw || '')
.split(/[\n,]/)
.forEach((item) => {
const text = String(item || '').trim();
if (text && !rows.includes(text)) rows.push(text);
});
return rows;
};
export function isChannelConfigured(channel: BotChannel): boolean {
const ctype = String(channel.channel_type || '').trim().toLowerCase();
if (ctype === 'email') {
const extra = channel.extra_config || {};
return Boolean(
String(extra.imapHost || '').trim() &&
String(extra.imapUsername || '').trim() &&
String(extra.imapPassword || '').trim() &&
String(extra.smtpHost || '').trim() &&
String(extra.smtpUsername || '').trim() &&
String(extra.smtpPassword || '').trim(),
);
}
return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
}
export function sanitizeChannelExtra(channelType: string, extra: Record<string, unknown>) {
const type = String(channelType || '').toLowerCase();
if (type === 'dashboard') return extra || {};
const next = { ...(extra || {}) };
delete next.sendProgress;
delete next.sendToolHints;
return next;
}
interface RenderBotChannelFieldsOptions {
channel: BotChannel;
lc: ChannelMessages;
onPatch: (patch: Partial<BotChannel>) => void;
passwordToggleLabels: PasswordToggleLabels;
}
export function renderBotChannelFields({
channel,
lc,
onPatch,
passwordToggleLabels,
}: RenderBotChannelFieldsOptions) {
const ctype = String(channel.channel_type).toLowerCase();
if (ctype === 'telegram') {
return (
<>
<PasswordInput className="input" placeholder={lc.telegramToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input
className="input"
placeholder={lc.proxy}
value={String((channel.extra_config || {}).proxy || '')}
onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), proxy: e.target.value } })}
autoComplete="off"
/>
<label className="field-label">
<input
type="checkbox"
checked={Boolean((channel.extra_config || {}).replyToMessage)}
onChange={(e) =>
onPatch({ extra_config: { ...(channel.extra_config || {}), replyToMessage: e.target.checked } })
}
style={{ marginRight: 6 }}
/>
{lc.replyToMessage}
</label>
</>
);
}
if (ctype === 'feishu') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
<input className="input" placeholder={lc.encryptKey} value={String((channel.extra_config || {}).encryptKey || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), encryptKey: e.target.value } })} autoComplete="off" />
<input className="input" placeholder={lc.verificationToken} value={String((channel.extra_config || {}).verificationToken || '')} onChange={(e) => onPatch({ extra_config: { ...(channel.extra_config || {}), verificationToken: e.target.value } })} autoComplete="off" />
</>
);
}
if (ctype === 'dingtalk') {
return (
<>
<input className="input" placeholder={lc.clientId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.clientSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'slack') {
return (
<>
<input className="input" placeholder={lc.botToken} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.appToken} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'qq') {
return (
<>
<input className="input" placeholder={lc.appId} value={channel.external_app_id || ''} onChange={(e) => onPatch({ external_app_id: e.target.value })} autoComplete="off" />
<PasswordInput className="input" placeholder={lc.appSecret} value={channel.app_secret || ''} onChange={(e) => onPatch({ app_secret: e.target.value })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</>
);
}
if (ctype === 'email') {
const extra = channel.extra_config || {};
return (
<>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.consentGranted)}
onChange={(e) => onPatch({ extra_config: { ...extra, consentGranted: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailConsentGranted}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailFromAddress}</label>
<input
className="input"
value={String(extra.fromAddress || '')}
onChange={(e) => onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })}
autoComplete="off"
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{lc.emailAllowFrom}</label>
<textarea
className="textarea"
rows={3}
value={parseChannelListValue(extra.allowFrom)}
onChange={(e) => onPatch({ extra_config: { ...extra, allowFrom: parseChannelListInput(e.target.value) } })}
placeholder={lc.emailAllowFromPlaceholder}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapHost}</label>
<input className="input" value={String(extra.imapHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.imapPort ?? 993)} onChange={(e) => onPatch({ extra_config: { ...extra, imapPort: Number(e.target.value || 993) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapUsername}</label>
<input className="input" value={String(extra.imapUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPassword}</label>
<PasswordInput className="input" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapMailbox}</label>
<input className="input" value={String(extra.imapMailbox || 'INBOX')} onChange={(e) => onPatch({ extra_config: { ...extra, imapMailbox: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.imapUseSsl ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, imapUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailImapUseSsl}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpHost}</label>
<input className="input" value={String(extra.smtpHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.smtpPort ?? 587)} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPort: Number(e.target.value || 587) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpUsername}</label>
<input className="input" value={String(extra.smtpUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPassword}</label>
<PasswordInput className="input" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" toggleLabels={passwordToggleLabels} />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseTls ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseTls: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailSmtpUseTls}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseSsl)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailSmtpUseSsl}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.autoReplyEnabled ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, autoReplyEnabled: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailAutoReplyEnabled}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailPollIntervalSeconds}</label>
<input className="input mono" type="number" min="5" max="3600" value={String(extra.pollIntervalSeconds ?? 30)} onChange={(e) => onPatch({ extra_config: { ...extra, pollIntervalSeconds: Number(e.target.value || 30) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailMaxBodyChars}</label>
<input className="input mono" type="number" min="1" max="50000" value={String(extra.maxBodyChars ?? 12000)} onChange={(e) => onPatch({ extra_config: { ...extra, maxBodyChars: Number(e.target.value || 12000) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSubjectPrefix}</label>
<input className="input" value={String(extra.subjectPrefix || 'Re: ')} onChange={(e) => onPatch({ extra_config: { ...extra, subjectPrefix: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.markSeen ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, markSeen: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailMarkSeen}
</label>
</div>
</>
);
}
return null;
}

View File

@ -0,0 +1,4 @@
export { AgentFilesDrawerContent, BaseConfigDrawerContent, CronJobsDrawerContent, EnvParamsDrawerContent, ModelParamsDrawerContent } from './config-drawers/GeneralDrawers';
export { ChannelDrawerContent, TopicDrawerContent } from './config-drawers/ChannelTopicDrawers';
export { McpDrawerContent, SkillsAddBar, SkillsDrawerContent } from './config-drawers/SkillsMcpDrawers';
export type { PasswordToggleLabels, SystemTimezoneOption } from './config-drawers/shared';

View File

@ -0,0 +1,359 @@
import type { RefObject } from 'react';
import {
Boxes,
ChevronLeft,
ChevronRight,
EllipsisVertical,
ExternalLink,
Gauge,
Power,
Search,
Square,
Trash2,
X,
} from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { DashboardMessages } from './dashboard-drawers/types';
import type { NodeBotGroup } from '../botDashboardShared';
interface BotDashboardBotListPanelProps {
botCount: number;
botListMenuOpen: boolean;
botListMenuRef: RefObject<HTMLDivElement | null>;
botListPage: number;
botListPageSizeReady: boolean;
botListQuery: string;
botListTotalPages: number;
botSearchInputName: string;
controlStateByBot: Record<string, string | undefined>;
filteredBotCount: number;
isBatchOperating: boolean;
isZh: boolean;
normalizedBotListQuery: string;
onBatchStartBots: () => void;
onBatchStopBots: () => void;
onNextPage: () => void;
onOpenCreateWizard?: () => void;
onOpenImageFactory?: () => void;
onOpenResourceMonitor: (botId: string) => void;
onPrevPage: () => void;
onQueryChange: (value: string) => void;
onRemoveBot: (botId: string) => void;
onSelectBot: (botId: string) => void;
onSetBotEnabled: (botId: string, enabled: boolean) => void;
onToggleBotListMenu: () => void;
onToggleBotRunning: (botId: string, dockerStatus: string) => void;
operatingBotId: string | null;
pagedBotGroups: NodeBotGroup[];
selectedBotId: string;
t: DashboardMessages;
}
export function BotDashboardBotListPanel({
botCount,
botListMenuOpen,
botListMenuRef,
botListPage,
botListPageSizeReady,
botListQuery,
botListTotalPages,
botSearchInputName,
controlStateByBot,
filteredBotCount,
isBatchOperating,
isZh,
normalizedBotListQuery,
onBatchStartBots,
onBatchStopBots,
onNextPage,
onOpenCreateWizard,
onOpenImageFactory,
onOpenResourceMonitor,
onPrevPage,
onQueryChange,
onRemoveBot,
onSelectBot,
onSetBotEnabled,
onToggleBotListMenu,
onToggleBotRunning,
operatingBotId,
pagedBotGroups,
selectedBotId,
t,
}: BotDashboardBotListPanelProps) {
return (
<section className="panel stack ops-bot-list">
<div className="row-between">
<h2 style={{ fontSize: 18 }}>
{normalizedBotListQuery
? `${t.titleBots} (${filteredBotCount}/${botCount})`
: `${t.titleBots} (${botCount})`}
</h2>
<div className="ops-list-actions" ref={botListMenuRef}>
{onOpenImageFactory ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onOpenImageFactory}
tooltip={t.manageImages}
aria-label={t.manageImages}
>
<Boxes size={14} />
</LucentIconButton>
) : null}
{onOpenCreateWizard ? (
<button className="btn btn-primary btn-sm" onClick={onOpenCreateWizard}>
{t.newBot}
</button>
) : null}
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleBotListMenu}
tooltip={t.extensions}
aria-label={t.extensions}
aria-haspopup="menu"
aria-expanded={botListMenuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{botListMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={t.extensions}>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={onBatchStartBots}
>
<Power size={14} />
<span>{t.batchStart}</span>
</button>
<button
className="ops-more-item"
role="menuitem"
disabled={isBatchOperating}
onClick={onBatchStopBots}
>
<Square size={14} />
<span>{t.batchStop}</span>
</button>
</div>
) : null}
</div>
</div>
<div className="ops-bot-list-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={botListQuery}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={t.botSearchPlaceholder}
aria-label={t.botSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
name={botSearchInputName}
id={botSearchInputName}
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => onQueryChange('')}
title={botListQuery.trim() ? t.clearSearch : t.searchAction}
aria-label={botListQuery.trim() ? t.clearSearch : t.searchAction}
>
{botListQuery.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
</div>
<div className="list-scroll">
{!botListPageSizeReady ? <div className="ops-bot-list-empty">{t.syncingPageSize}</div> : null}
{botListPageSizeReady
? pagedBotGroups.map((group) => {
const modeSet = Array.from(
new Set(
group.bots.map((bot) => {
const transport = String(bot.transport_kind || '').trim() || 'direct';
const runtime = String(bot.runtime_kind || '').trim() || 'docker';
return `${transport}/${runtime}`;
}),
),
);
return (
<div key={group.key} className="ops-bot-group">
<div className="ops-bot-group-head">
<div className="ops-bot-group-title-wrap">
<div className="ops-bot-group-title">{group.label}</div>
<div className="ops-bot-group-subtitle">
<span className="ops-bot-group-chip">{t.nodeLabel}</span>
<span className="mono">{group.nodeId}</span>
<span className="ops-bot-group-sep"></span>
<span>{t.nodeGroupCount(group.bots.length)}</span>
{modeSet.length > 0 ? (
<>
<span className="ops-bot-group-sep"></span>
<span className="mono">{modeSet.join(', ')}</span>
</>
) : null}
</div>
</div>
</div>
{group.bots.map((bot) => {
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isEnabled = bot.enabled !== false;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
const isEnabling = controlState === 'enabling';
const isDisabling = controlState === 'disabling';
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
const runtimeKind = String(bot.runtime_kind || '').trim().toLowerCase() || 'docker';
return (
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
onClick={() => onSelectBot(bot.id)}
>
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
<div className="ops-bot-name">{bot.name}</div>
<LucentIconButton
className="ops-bot-open-inline"
onClick={(event) => {
event.stopPropagation();
const target = `${window.location.origin}/bot/${encodeURIComponent(bot.id)}`;
window.open(target, '_blank', 'noopener,noreferrer');
}}
tooltip={isZh ? '新页面打开' : 'Open in new page'}
aria-label={isZh ? '新页面打开' : 'Open in new page'}
>
<ExternalLink size={11} />
</LucentIconButton>
</div>
<div className="mono ops-bot-id">{bot.id}</div>
</div>
<div className="ops-bot-top-actions">
{!isEnabled ? <span className="badge badge-err">{t.disabled}</span> : null}
<span className={`ops-runtime-kind-badge ${runtimeKind === 'native' ? 'is-native' : 'is-docker'}`}>
{runtimeKind === 'native' ? 'NATIVE' : 'DOCKER'}
</span>
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
</div>
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-meta">
<span className="mono">
{(String(bot.transport_kind || '').trim() || 'direct')}/{(String(bot.runtime_kind || '').trim() || 'docker')}
</span>
<span> · </span>
<span className="mono">{String(bot.core_adapter || '').trim() || 'nanobot'}</span>
</div>
<div className="ops-bot-actions">
<label
className="ops-bot-enable-switch"
title={isEnabled ? t.disable : t.enable}
onClick={(event) => event.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
disabled={isOperating || isEnabling || isDisabling}
onChange={(event) => onSetBotEnabled(bot.id, event.target.checked)}
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
/>
<span className="ops-bot-enable-switch-track" />
</label>
<div className="ops-bot-actions-main">
<LucentIconButton
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
disabled={isOperating || !isEnabled}
onClick={(event) => {
event.stopPropagation();
onToggleBotRunning(bot.id, bot.docker_status);
}}
tooltip={isRunning ? t.stop : t.start}
aria-label={isRunning ? t.stop : t.start}
>
{isStarting || isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : isRunning ? <Square size={14} /> : <Power size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
disabled={isOperating || !isEnabled}
onClick={(event) => {
event.stopPropagation();
onOpenResourceMonitor(bot.id);
}}
tooltip={isZh ? '资源监测' : 'Resource Monitor'}
aria-label={isZh ? '资源监测' : 'Resource Monitor'}
>
<Gauge size={14} />
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
disabled={isOperating || !isEnabled}
onClick={(event) => {
event.stopPropagation();
onRemoveBot(bot.id);
}}
tooltip={t.delete}
aria-label={t.delete}
>
<Trash2 size={14} />
</LucentIconButton>
</div>
</div>
</div>
);
})}
</div>
);
})
: null}
{botListPageSizeReady && filteredBotCount === 0 ? (
<div className="ops-bot-list-empty">{t.botSearchNoResult}</div>
) : null}
</div>
{botListPageSizeReady ? (
<div className="ops-bot-list-pagination">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={onPrevPage}
disabled={botListPage <= 1}
tooltip={t.paginationPrev}
aria-label={t.paginationPrev}
>
<ChevronLeft size={14} />
</LucentIconButton>
<div className="ops-bot-list-page-indicator pager-status">{t.paginationPage(botListPage, botListTotalPages)}</div>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn pager-icon-btn"
onClick={onNextPage}
disabled={botListPage >= botListTotalPages}
tooltip={t.paginationNext}
aria-label={t.paginationNext}
>
<ChevronRight size={14} />
</LucentIconButton>
</div>
) : null}
</section>
);
}

View File

@ -0,0 +1,14 @@
import { BotDashboardChannelTopicDrawers } from './dashboard-drawers/BotDashboardChannelTopicDrawers';
import { BotDashboardOperationsDrawers } from './dashboard-drawers/BotDashboardOperationsDrawers';
import { BotDashboardPrimaryDrawers } from './dashboard-drawers/BotDashboardPrimaryDrawers';
import type { BotDashboardConfigDrawersProps } from './dashboard-drawers/types';
export function BotDashboardConfigDrawers(props: BotDashboardConfigDrawersProps) {
return (
<>
<BotDashboardPrimaryDrawers {...props} />
<BotDashboardChannelTopicDrawers {...props} />
<BotDashboardOperationsDrawers {...props} />
</>
);
}

View File

@ -0,0 +1,275 @@
import { useMemo } from 'react';
import {
ChevronDown,
ChevronUp,
Copy,
Download,
Eye,
FileText,
Pencil,
Reply,
ThumbsDown,
ThumbsUp,
UserRound,
} from 'lucide-react';
import nanobotLogo from '../../../assets/nanobot-logo.png';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import type { ChatMessage } from '../../../types/bot';
import type { DashboardMessages } from './dashboard-drawers/types';
import { WorkspaceMarkdownView } from '../shared/workspaceMarkdown';
import {
formatClock,
formatConversationDate,
normalizeDashboardAttachmentPath,
workspaceFileAction,
} from '../botDashboardShared';
import {
normalizeAssistantMessageText,
normalizeUserMessageText,
summarizeProgressText,
} from '../messageParser';
interface BotDashboardConversationNodesProps {
conversation: ChatMessage[];
expandedProgressByKey: Record<string, boolean>;
expandedUserByKey: Record<string, boolean>;
feedbackSavingByMessageId: Record<number, boolean>;
isZh: boolean;
onCopyAssistantReply: (text: string) => Promise<unknown> | void;
onCopyUserPrompt: (text: string) => Promise<unknown> | void;
onEditUserPrompt: (text: string) => void;
onOpenWorkspacePath: (path: string) => void;
onQuoteAssistantReply: (item: ChatMessage) => void;
onResolveMediaSrc: (src: string, baseFilePath?: string) => string;
onSubmitAssistantFeedback: (item: ChatMessage, value: 'up' | 'down') => Promise<unknown> | void;
onToggleProgressExpansion: (key: string) => void;
onToggleUserExpansion: (key: string) => void;
t: DashboardMessages;
workspaceDownloadExtensionSet: ReadonlySet<string>;
}
function shouldCollapseProgress(text: string) {
const normalized = String(text || '').trim();
if (!normalized) return false;
const lines = normalized.split('\n').length;
return lines > 6 || normalized.length > 520;
}
export function BotDashboardConversationNodes({
conversation,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
isZh,
onCopyAssistantReply,
onCopyUserPrompt,
onEditUserPrompt,
onOpenWorkspacePath,
onQuoteAssistantReply,
onResolveMediaSrc,
onSubmitAssistantFeedback,
onToggleProgressExpansion,
onToggleUserExpansion,
t,
workspaceDownloadExtensionSet,
}: BotDashboardConversationNodesProps) {
const nodes = useMemo(
() =>
conversation.map((item, index) => {
const itemKey = `${item.id || item.ts}-${index}`;
const isProgressBubble = item.role !== 'user' && (item.kind || 'final') === 'progress';
const isUserBubble = item.role === 'user';
const fullText = String(item.text || '');
const summaryText = isProgressBubble ? summarizeProgressText(fullText, isZh) : fullText;
const hasSummary = isProgressBubble && summaryText.trim().length > 0 && summaryText.trim() !== fullText.trim();
const progressCollapsible = isProgressBubble && (hasSummary || shouldCollapseProgress(fullText));
const normalizedUserText = isUserBubble ? normalizeUserMessageText(fullText) : '';
const userLineCount = isUserBubble ? normalizedUserText.split('\n').length : 0;
const userCollapsible = isUserBubble && userLineCount > 5;
const collapsible = isProgressBubble ? progressCollapsible : userCollapsible;
const expanded = isProgressBubble ? Boolean(expandedProgressByKey[itemKey]) : Boolean(expandedUserByKey[itemKey]);
const displayText = isProgressBubble && !expanded ? summaryText : fullText;
const currentDayKey = new Date(item.ts).toDateString();
const prevDayKey = index > 0 ? new Date(conversation[index - 1].ts).toDateString() : '';
const showDateDivider = index === 0 || currentDayKey !== prevDayKey;
return (
<div key={itemKey} data-chat-message-id={item.id ? String(item.id) : undefined}>
{showDateDivider ? (
<div className="ops-chat-date-divider" aria-label={formatConversationDate(item.ts, isZh)}>
<span>{formatConversationDate(item.ts, isZh)}</span>
</div>
) : null}
<div className={`ops-chat-row ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
<div className={`ops-chat-item ${item.role === 'user' ? 'is-user' : 'is-assistant'}`}>
{item.role !== 'user' ? (
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
) : null}
{item.role === 'user' ? (
<div className="ops-chat-hover-actions ops-chat-hover-actions-user">
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onEditUserPrompt(item.text)}
tooltip={t.editPrompt}
aria-label={t.editPrompt}
>
<Pencil size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyUserPrompt(item.text)}
tooltip={t.copyPrompt}
aria-label={t.copyPrompt}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
<div className={`ops-chat-bubble ${item.role === 'user' ? 'user' : 'assistant'} ${(item.kind || 'final') === 'progress' ? 'progress' : ''}`}>
<div className="ops-chat-meta">
<span>{item.role === 'user' ? t.you : 'Nanobot'}</span>
<div className="ops-chat-meta-right">
<span className="mono">{formatClock(item.ts)}</span>
{collapsible ? (
<LucentIconButton
className="ops-chat-expand-icon-btn"
onClick={() => {
if (isProgressBubble) {
onToggleProgressExpansion(itemKey);
return;
}
onToggleUserExpansion(itemKey);
}}
tooltip={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
aria-label={expanded ? (isZh ? '收起' : 'Collapse') : (isZh ? '展开' : 'Expand')}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
) : null}
</div>
</div>
<div className={`ops-chat-text ${collapsible && !expanded ? (isUserBubble ? 'is-collapsed-user' : 'is-collapsed') : ''}`}>
{item.text ? (
item.role === 'user' ? (
<>
{item.quoted_reply ? (
<div className="ops-user-quoted-reply">
<div className="ops-user-quoted-label">{t.quotedReplyLabel}</div>
<div className="ops-user-quoted-text">{normalizeAssistantMessageText(item.quoted_reply)}</div>
</div>
) : null}
<div className="whitespace-pre-wrap">{normalizeUserMessageText(displayText)}</div>
</>
) : (
<WorkspaceMarkdownView
content={displayText}
openWorkspacePath={onOpenWorkspacePath}
resolveMediaSrc={onResolveMediaSrc}
/>
)
) : null}
{(item.attachments || []).length > 0 ? (
<div className="ops-chat-attachments">
{(item.attachments || []).map((rawPath) => {
const filePath = normalizeDashboardAttachmentPath(rawPath);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
key={`${item.ts}-${filePath}`}
className="ops-attach-link mono"
href="#"
onClick={(event) => {
event.preventDefault();
onOpenWorkspacePath(filePath);
}}
title={fileAction === 'download' ? t.download : fileAction === 'preview' ? t.previewTitle : t.fileNotPreviewable}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})}
</div>
) : null}
{item.role === 'assistant' && !isProgressBubble ? (
<div className="ops-chat-reply-actions">
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'up' ? 'active-up' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'up')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={t.goodReply}
aria-label={t.goodReply}
>
<ThumbsUp size={13} />
</LucentIconButton>
<LucentIconButton
className={`ops-chat-inline-action ${item.feedback === 'down' ? 'active-down' : ''}`}
onClick={() => void onSubmitAssistantFeedback(item, 'down')}
disabled={Boolean(item.id && feedbackSavingByMessageId[item.id])}
tooltip={t.badReply}
aria-label={t.badReply}
>
<ThumbsDown size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => onQuoteAssistantReply(item)}
tooltip={t.quoteReply}
aria-label={t.quoteReply}
>
<Reply size={13} />
</LucentIconButton>
<LucentIconButton
className="ops-chat-inline-action"
onClick={() => void onCopyAssistantReply(item.text)}
tooltip={t.copyReply}
aria-label={t.copyReply}
>
<Copy size={13} />
</LucentIconButton>
</div>
) : null}
</div>
</div>
</div>
{item.role === 'user' ? (
<div className="ops-avatar user" title={t.user}>
<UserRound size={18} />
</div>
) : null}
</div>
</div>
);
}),
[
conversation,
expandedProgressByKey,
expandedUserByKey,
feedbackSavingByMessageId,
isZh,
onCopyAssistantReply,
onCopyUserPrompt,
onEditUserPrompt,
onOpenWorkspacePath,
onQuoteAssistantReply,
onResolveMediaSrc,
onSubmitAssistantFeedback,
onToggleProgressExpansion,
onToggleUserExpansion,
t,
workspaceDownloadExtensionSet,
],
);
return <>{nodes}</>;
}

View File

@ -0,0 +1,271 @@
import type { RefObject } from 'react';
import type { BotState } from '../../../types/bot';
import type { WorkspaceNode } from '../botDashboardShared';
import { RuntimePanelHeader, RuntimeStateCard } from './RuntimePanelParts';
import { WorkspaceExplorerPanel } from './WorkspacePanelParts';
import type { ChannelMessages, DashboardMessages } from './dashboard-drawers/types';
interface BotDashboardRuntimePanelProps {
compactMode: boolean;
compactPanelTab: 'chat' | 'runtime';
displayState: string;
filteredWorkspaceEntries: WorkspaceNode[];
forcedBotId?: string;
forcedBotMissing: boolean;
hideWorkspaceHoverCard: () => void;
isCompactListPage: boolean;
lc: ChannelMessages;
loadBotEnvParams: (botId: string) => Promise<unknown> | void;
loadBotSkills: (botId: string) => Promise<unknown> | void;
loadCronJobs: (botId: string) => Promise<unknown> | void;
loadWorkspaceTree: (botId: string, path: string) => Promise<unknown> | void;
onClearConversationHistory: () => void;
onEnsureSelectedBotDetail: () => Promise<BotState | undefined>;
onExportConversationJson: () => void;
onOpenChannelModal: (botId: string) => void;
onOpenMcpModal: (botId: string) => void;
onOpenTopicModal: (botId: string) => void;
onOpenWorkspaceFilePreview: (path: string) => void;
onRestartBot: (botId: string, dockerStatus: string) => void;
onSetRuntimeMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void;
onShowWorkspaceHoverCard: (node: WorkspaceNode, anchor: HTMLElement) => void;
operatingBotId: string | null;
runtimeMenuOpen: boolean;
runtimeMenuRef: RefObject<HTMLDivElement | null>;
runtimeMoreLabel: string;
selectedBot?: BotState;
selectedBotEnabled: boolean;
selectedBotId: string;
setProviderTestResult: (value: string) => void;
setShowAgentModal: (value: boolean) => void;
setShowBaseModal: (value: boolean) => void;
setShowCronModal: (value: boolean) => void;
setShowEnvParamsModal: (value: boolean) => void;
setShowParamModal: (value: boolean) => void;
setShowSkillsModal: (value: boolean) => void;
setWorkspaceAutoRefresh: (value: boolean | ((prev: boolean) => boolean)) => void;
setWorkspaceQuery: (value: string) => void;
showCompactBotPageClose: boolean;
t: DashboardMessages;
workspaceAutoRefresh: boolean;
workspaceCurrentPath: string;
workspaceDownloadExtensionSet: ReadonlySet<string>;
workspaceError: string;
workspaceFileLoading: boolean;
workspaceFiles: WorkspaceNode[];
workspaceLoading: boolean;
workspaceParentPath: string | null;
workspacePathDisplay: string;
workspaceQuery: string;
workspaceSearchInputName: string;
workspaceSearchLoading: boolean;
}
export function BotDashboardRuntimePanel({
compactMode,
compactPanelTab,
displayState,
filteredWorkspaceEntries,
forcedBotId,
forcedBotMissing,
hideWorkspaceHoverCard,
isCompactListPage,
lc,
loadBotEnvParams,
loadBotSkills,
loadCronJobs,
loadWorkspaceTree,
onClearConversationHistory,
onEnsureSelectedBotDetail,
onExportConversationJson,
onOpenChannelModal,
onOpenMcpModal,
onOpenTopicModal,
onOpenWorkspaceFilePreview,
onRestartBot,
onSetRuntimeMenuOpen,
onShowWorkspaceHoverCard,
operatingBotId,
runtimeMenuOpen,
runtimeMenuRef,
runtimeMoreLabel,
selectedBot,
selectedBotEnabled,
selectedBotId,
setProviderTestResult,
setShowAgentModal,
setShowBaseModal,
setShowCronModal,
setShowEnvParamsModal,
setShowParamModal,
setShowSkillsModal,
setWorkspaceAutoRefresh,
setWorkspaceQuery,
showCompactBotPageClose,
t,
workspaceAutoRefresh,
workspaceCurrentPath,
workspaceDownloadExtensionSet,
workspaceError,
workspaceFileLoading,
workspaceFiles,
workspaceLoading,
workspaceParentPath,
workspacePathDisplay,
workspaceQuery,
workspaceSearchInputName,
workspaceSearchLoading,
}: BotDashboardRuntimePanelProps) {
return (
<section className={`panel stack ops-runtime-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'runtime') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-runtime-shell">
<RuntimePanelHeader
labels={{
agent: t.agent,
base: t.base,
channels: t.channels,
clearHistory: t.clearHistory,
cronViewer: t.cronViewer,
envParams: t.envParams,
exportHistory: t.exportHistory,
mcp: t.mcp,
more: runtimeMoreLabel,
params: t.params,
restart: t.restart,
runtime: t.runtime,
skills: t.skills,
topic: t.topic,
}}
menuDisabled={!selectedBotEnabled}
menuOpen={runtimeMenuOpen}
menuRef={runtimeMenuRef}
onClearHistory={() => {
onSetRuntimeMenuOpen(false);
onClearConversationHistory();
}}
onExportHistory={() => {
onSetRuntimeMenuOpen(false);
onExportConversationJson();
}}
onOpenAgent={() => {
onSetRuntimeMenuOpen(false);
void (async () => {
await onEnsureSelectedBotDetail();
setShowAgentModal(true);
})();
}}
onOpenBase={() => {
onSetRuntimeMenuOpen(false);
void (async () => {
await onEnsureSelectedBotDetail();
setProviderTestResult('');
setShowBaseModal(true);
})();
}}
onOpenChannels={() => {
onSetRuntimeMenuOpen(false);
onOpenChannelModal(selectedBot.id);
}}
onOpenCron={() => {
onSetRuntimeMenuOpen(false);
void loadCronJobs(selectedBot.id);
setShowCronModal(true);
}}
onOpenEnvParams={() => {
onSetRuntimeMenuOpen(false);
void (async () => {
await loadBotEnvParams(selectedBot.id);
setShowEnvParamsModal(true);
})();
}}
onOpenMcp={() => {
onSetRuntimeMenuOpen(false);
onOpenMcpModal(selectedBot.id);
}}
onOpenParams={() => {
onSetRuntimeMenuOpen(false);
void (async () => {
await onEnsureSelectedBotDetail();
setShowParamModal(true);
})();
}}
onOpenSkills={() => {
onSetRuntimeMenuOpen(false);
void loadBotSkills(selectedBot.id);
setShowSkillsModal(true);
}}
onOpenTopic={() => {
onSetRuntimeMenuOpen(false);
onOpenTopicModal(selectedBot.id);
}}
onRestart={() => onRestartBot(selectedBot.id, selectedBot.docker_status)}
onToggleMenu={() => onSetRuntimeMenuOpen((prev) => !prev)}
restartDisabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
restartLoading={operatingBotId === selectedBot.id}
/>
<div className="ops-runtime-scroll">
<RuntimeStateCard
displayState={displayState}
modelName={selectedBot.llm_model || '-'}
/>
<WorkspaceExplorerPanel
entries={filteredWorkspaceEntries}
labels={{
autoRefresh: lc.autoRefresh,
clearSearch: t.clearSearch,
download: t.download,
emptyDir: t.emptyDir,
fileNotPreviewable: t.fileNotPreviewable,
folder: t.folder,
goUp: t.goUp,
goUpTitle: t.goUpTitle,
loadingDir: t.loadingDir,
noPreviewFile: t.noPreviewFile,
openFolderTitle: t.openFolderTitle,
openingPreview: t.openingPreview,
previewTitle: t.previewTitle,
refreshHint: lc.refreshHint,
searchAction: t.searchAction,
workspaceHint: t.workspaceHint,
workspaceSearchNoResult: t.workspaceSearchNoResult,
workspaceSearchPlaceholder: t.workspaceSearchPlaceholder,
workspaceTitle: t.workspaceOutputs,
}}
normalizedWorkspaceQuery={workspaceQuery.trim().toLowerCase()}
onHoverNode={onShowWorkspaceHoverCard}
onLeaveNode={hideWorkspaceHoverCard}
onOpenFile={onOpenWorkspaceFilePreview}
onOpenFolder={(path) => {
void loadWorkspaceTree(selectedBotId, path);
}}
onQueryChange={setWorkspaceQuery}
onRefresh={() => {
void loadWorkspaceTree(selectedBot.id, workspaceCurrentPath);
}}
onToggleAutoRefresh={() => setWorkspaceAutoRefresh((prev) => !prev)}
query={workspaceQuery}
queryInputName={workspaceSearchInputName}
showParentEntry={workspaceParentPath !== null}
showPreviewFileHint={workspaceFiles.length === 0}
workspaceAutoRefresh={workspaceAutoRefresh}
workspaceError={workspaceError}
workspaceFileLoading={workspaceFileLoading}
workspaceLoading={workspaceLoading || !selectedBotId}
workspaceParentPath={workspaceParentPath}
workspacePathDisplay={workspacePathDisplay}
workspaceSearchLoading={workspaceSearchLoading}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
/>
</div>
</div>
) : (
<div style={{ color: 'var(--muted)' }}>
{forcedBotMissing ? `${t.noTelemetry}: ${String(forcedBotId).trim()}` : t.noTelemetry}
</div>
)}
</section>
);
}

View File

@ -0,0 +1,314 @@
import type { ChangeEvent, KeyboardEvent, RefObject } from 'react';
import { ArrowUp, ChevronLeft, Clock3, Command, Mic, Paperclip, Plus, RefreshCw, RotateCcw, Square } from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { dashboardZhCn } from '../../../i18n/dashboard.zh-cn';
import type { QuotedReply } from '../botDashboardShared';
import { ChatTopContextBar } from './ChatPanelParts';
interface ChatComposerDockProps {
activeControlCommand: string;
allowedAttachmentExtensions: string[];
attachmentUploadPercent: number | null;
canChat: boolean;
canSendControlCommand: boolean;
chatDateJumping: boolean;
chatDatePanelPosition: { bottom: number; right: number } | null;
chatDatePickerOpen: boolean;
chatDateTriggerRef: RefObject<HTMLButtonElement | null>;
chatDateValue: string;
command: string;
composerTextareaRef: RefObject<HTMLTextAreaElement | null>;
controlCommandPanelOpen: boolean;
controlCommandPanelRef: RefObject<HTMLDivElement | null>;
disabledSend: boolean;
filePickerRef: RefObject<HTMLInputElement | null>;
isCompactMobile: boolean;
isInterrupting: boolean;
isUploadingAttachments: boolean;
isVoiceRecording: boolean;
isVoiceTranscribing: boolean;
isZh: boolean;
onClearQuotedReply: () => void;
onCommandChange: (value: string) => void;
onCloseChatDatePicker: () => void;
onComposerKeyDown: (event: KeyboardEvent<HTMLTextAreaElement>) => void;
onDateValueChange: (value: string) => void;
onInterruptExecution: () => void;
onJumpConversationToDate: () => void;
onOpenWorkspacePath: (path: string) => void;
onPickAttachments: (event: ChangeEvent<HTMLInputElement>) => void;
onRemovePendingAttachment: (path: string) => void;
onSend: () => void;
onSendControlCommand: (command: '/restart' | '/new') => void;
onToggleChatDatePicker: () => void;
onToggleControlPanel: () => void;
onTriggerPickAttachments: () => void;
onVoiceInput: () => void;
pendingAttachments: string[];
quotedReply: QuotedReply | null;
selectedBotId: string;
showInterruptSubmitAction: boolean;
t: typeof dashboardZhCn;
voiceCountdown: number;
workspaceDownloadExtensionSet: ReadonlySet<string>;
}
export function ChatComposerDock({
activeControlCommand,
allowedAttachmentExtensions,
attachmentUploadPercent,
canChat,
canSendControlCommand,
chatDateJumping,
chatDatePanelPosition,
chatDatePickerOpen,
chatDateTriggerRef,
chatDateValue,
command,
composerTextareaRef,
controlCommandPanelOpen,
controlCommandPanelRef,
disabledSend,
filePickerRef,
isCompactMobile,
isInterrupting,
isUploadingAttachments,
isVoiceRecording,
isVoiceTranscribing,
isZh,
onClearQuotedReply,
onCommandChange,
onCloseChatDatePicker,
onComposerKeyDown,
onDateValueChange,
onInterruptExecution,
onJumpConversationToDate,
onOpenWorkspacePath,
onPickAttachments,
onRemovePendingAttachment,
onSend,
onSendControlCommand,
onToggleChatDatePicker,
onToggleControlPanel,
onTriggerPickAttachments,
onVoiceInput,
pendingAttachments,
quotedReply,
selectedBotId,
showInterruptSubmitAction,
t,
voiceCountdown,
workspaceDownloadExtensionSet,
}: ChatComposerDockProps) {
return (
<div className="ops-chat-dock">
<ChatTopContextBar
onClearQuotedReply={onClearQuotedReply}
onOpenWorkspacePath={onOpenWorkspacePath}
onRemovePendingAttachment={onRemovePendingAttachment}
pendingAttachments={pendingAttachments}
quotedReply={quotedReply}
quotedReplyLabel={t.quotedReplyLabel}
workspaceDownloadExtensionSet={workspaceDownloadExtensionSet}
/>
{isUploadingAttachments ? (
<div className="ops-upload-progress" aria-live="polite">
<div className={`ops-upload-progress-track ${attachmentUploadPercent === null ? 'is-indeterminate' : ''}`}>
<div
className="ops-upload-progress-fill"
style={{ width: `${Math.max(3, Number(attachmentUploadPercent ?? 24))}%` }}
/>
</div>
<span className="ops-upload-progress-text mono">
{attachmentUploadPercent === null
? t.uploadingFile
: `${t.uploadingFile} ${attachmentUploadPercent}%`}
</span>
</div>
) : null}
<div className="ops-composer">
<input
ref={filePickerRef}
type="file"
multiple
accept={allowedAttachmentExtensions.length > 0 ? allowedAttachmentExtensions.join(',') : undefined}
onChange={onPickAttachments}
style={{ display: 'none' }}
/>
<div className={`ops-composer-shell ${controlCommandPanelOpen ? 'is-command-open' : ''}`}>
<div className="ops-composer-float-controls" ref={controlCommandPanelRef}>
<div className={`ops-control-command-drawer ${controlCommandPanelOpen ? 'is-open' : ''}`}>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => onSendControlCommand('/restart')}
aria-label="/restart"
title="/restart"
>
{activeControlCommand === '/restart' ? <RefreshCw size={11} className="animate-spin" /> : <RotateCcw size={11} />}
<span className="mono">/restart</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!canSendControlCommand || Boolean(activeControlCommand) || isInterrupting}
onClick={() => onSendControlCommand('/new')}
aria-label="/new"
title="/new"
>
{activeControlCommand === '/new' ? <RefreshCw size={11} className="animate-spin" /> : <Plus size={11} />}
<span className="mono">/new</span>
</button>
<button
type="button"
className="ops-control-command-chip"
disabled={!selectedBotId || !canChat || Boolean(activeControlCommand) || isInterrupting}
onClick={onInterruptExecution}
aria-label="/stop"
title="/stop"
>
{isInterrupting ? <RefreshCw size={11} className="animate-spin" /> : <Square size={11} />}
<span className="mono">/stop</span>
</button>
<button
type="button"
className="ops-control-command-chip"
ref={chatDateTriggerRef}
disabled={!selectedBotId || chatDateJumping}
onClick={onToggleChatDatePicker}
aria-label={isZh ? '按日期定位对话' : 'Jump to date'}
title={isZh ? '按日期定位对话' : 'Jump to date'}
>
{chatDateJumping ? <RefreshCw size={11} className="animate-spin" /> : <Clock3 size={11} />}
<span className="mono">/time</span>
</button>
</div>
{chatDatePickerOpen ? (
<div
className="ops-control-date-panel"
style={chatDatePanelPosition ? { bottom: chatDatePanelPosition.bottom, right: chatDatePanelPosition.right } : undefined}
>
<label className="ops-control-date-label">
<span>{isZh ? '选择日期' : 'Select date'}</span>
<input
className="input ops-control-date-input"
type="date"
value={chatDateValue}
max={new Date().toISOString().slice(0, 10)}
onChange={(event) => onDateValueChange(event.target.value)}
/>
</label>
<div className="ops-control-date-actions">
<button
type="button"
className="btn btn-secondary btn-sm"
onClick={onCloseChatDatePicker}
>
{isZh ? '取消' : 'Cancel'}
</button>
<button
type="button"
className="btn btn-primary btn-sm"
disabled={chatDateJumping || !chatDateValue}
onClick={onJumpConversationToDate}
>
{chatDateJumping ? <RefreshCw size={14} className="animate-spin" /> : null}
<span style={{ marginLeft: chatDateJumping ? 6 : 0 }}>
{isZh ? '跳转' : 'Jump'}
</span>
</button>
</div>
</div>
) : null}
<button
type="button"
className={`ops-control-command-toggle ${controlCommandPanelOpen ? 'is-open' : ''}`}
onClick={onToggleControlPanel}
aria-label={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
title={controlCommandPanelOpen ? t.controlCommandsHide : t.controlCommandsShow}
>
{controlCommandPanelOpen ? <Command size={12} /> : <ChevronLeft size={13} />}
</button>
</div>
<textarea
ref={composerTextareaRef}
className="input ops-composer-input"
value={command}
onChange={(event) => onCommandChange(event.target.value)}
onKeyDown={onComposerKeyDown}
disabled={!canChat || isVoiceRecording || isVoiceTranscribing}
placeholder={canChat ? t.inputPlaceholder : t.disabledPlaceholder}
/>
<div className="ops-composer-tools-right">
{(isVoiceRecording || isVoiceTranscribing) ? (
<div className="ops-voice-inline" aria-live="polite">
<div className={`ops-voice-wave ${isVoiceRecording ? 'is-live' : ''} ${isCompactMobile ? 'is-mobile' : 'is-desktop'}`}>
{Array.from({ length: isCompactMobile ? 1 : 5 }).map((_, segmentIdx) => (
<div key={`vw-segment-${segmentIdx}`} className="ops-voice-wave-segment">
{Array.from({ length: isCompactMobile ? 28 : 18 }).map((_, idx) => {
const delayIndex = isCompactMobile ? idx : (segmentIdx * 18) + idx;
return (
<i
key={`vw-inline-${segmentIdx}-${idx}`}
style={{ animationDelay: `${(delayIndex % 14) * 0.06}s` }}
/>
);
})}
</div>
))}
</div>
<div className="ops-voice-countdown mono">
{isVoiceRecording ? `${voiceCountdown}s` : t.voiceTranscribing}
</div>
</div>
) : null}
<button
className={`ops-composer-inline-btn ${isVoiceRecording ? 'is-recording' : ''}`}
disabled={!canChat || isVoiceTranscribing}
onClick={onVoiceInput}
aria-label={isVoiceRecording ? t.voiceStop : t.voiceStart}
title={
isVoiceTranscribing
? t.voiceTranscribing
: isVoiceRecording
? t.voiceStop
: t.voiceStart
}
>
{isVoiceTranscribing ? (
<RefreshCw size={16} className="animate-spin" />
) : isVoiceRecording ? (
<Square size={16} />
) : (
<Mic size={16} />
)}
</button>
<LucentIconButton
className="ops-composer-inline-btn"
disabled={!canChat || isUploadingAttachments || isVoiceRecording || isVoiceTranscribing}
onClick={onTriggerPickAttachments}
tooltip={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
aria-label={isUploadingAttachments ? t.uploadingFile : t.uploadFile}
>
<Paperclip size={16} className={isUploadingAttachments ? 'animate-spin' : ''} />
</LucentIconButton>
<button
className={`ops-composer-submit-btn ${showInterruptSubmitAction ? 'is-interrupt' : ''}`}
disabled={disabledSend}
onClick={onSend}
aria-label={showInterruptSubmitAction ? t.interrupt : t.send}
title={showInterruptSubmitAction ? t.interrupt : t.send}
>
{showInterruptSubmitAction ? (
<Square size={15} />
) : (
<ArrowUp size={18} />
)}
</button>
</div>
</div>
</div>
</div>
);
}

View File

@ -0,0 +1,217 @@
import type { ReactNode, RefObject } from 'react';
import { Download, Eye, FileText, MessageCircle, MessageSquareText, X } from 'lucide-react';
import nanobotLogo from '../../../assets/nanobot-logo.png';
import { normalizeAssistantMessageText } from '../messageParser';
import { normalizeDashboardAttachmentPath, workspaceFileAction } from '../botDashboardShared';
import type { QuotedReply } from '../botDashboardShared';
interface ChatModeRailProps {
hasTopicUnread: boolean;
isZh: boolean;
runtimeViewMode: 'visual' | 'topic';
onChange: (mode: 'visual' | 'topic') => void;
}
export function ChatModeRail({ hasTopicUnread, isZh, runtimeViewMode, onChange }: ChatModeRailProps) {
return (
<div className="ops-main-mode-rail" role="tablist" aria-label={isZh ? '主面板视图切换' : 'Main panel view switch'}>
<button
className={`ops-main-mode-tab ${runtimeViewMode === 'visual' ? 'is-active' : ''}`}
onClick={() => onChange('visual')}
aria-label={isZh ? '对话视图' : 'Conversation view'}
role="tab"
aria-selected={runtimeViewMode === 'visual'}
>
<MessageCircle size={14} />
<span className="ops-main-mode-label">{isZh ? '对话' : 'Chat'}</span>
</button>
<button
className={`ops-main-mode-tab has-dot ${runtimeViewMode === 'topic' ? 'is-active' : ''}`}
onClick={() => onChange('topic')}
aria-label={isZh ? '主题视图' : 'Topic view'}
role="tab"
aria-selected={runtimeViewMode === 'topic'}
>
<MessageSquareText size={14} />
<span className="ops-main-mode-label-wrap">
<span className="ops-main-mode-label">{isZh ? '主题' : 'Topic'}</span>
{hasTopicUnread ? <span className="ops-switch-dot" aria-hidden="true" /> : null}
</span>
</button>
</div>
);
}
interface ConversationViewportProps {
bottomRef: RefObject<HTMLDivElement | null>;
chatScrollRef: RefObject<HTMLDivElement | null>;
conversationNodes: ReactNode;
emptyLabel: string;
hasConversation: boolean;
isChatEnabled: boolean;
isThinking: boolean;
onScroll: () => void;
thinkingLabel: string;
}
export function ConversationViewport({
bottomRef,
chatScrollRef,
conversationNodes,
emptyLabel,
hasConversation,
isChatEnabled,
isThinking,
onScroll,
thinkingLabel,
}: ConversationViewportProps) {
return (
<div className={`ops-chat-frame ${isChatEnabled ? '' : 'is-disabled'}`}>
<div className="ops-chat-scroll" ref={chatScrollRef} onScroll={onScroll}>
{hasConversation ? conversationNodes : <div className="ops-chat-empty">{emptyLabel}</div>}
{isThinking ? (
<div className="ops-chat-row is-assistant">
<div className="ops-chat-item is-assistant">
<div className="ops-avatar bot" title="Nanobot">
<img src={nanobotLogo} alt="Nanobot" />
</div>
<div className="ops-thinking-bubble">
<div className="ops-thinking-cloud">
<span className="dot" />
<span className="dot" />
<span className="dot" />
</div>
<div className="ops-thinking-text">{thinkingLabel}</div>
</div>
</div>
</div>
) : null}
<div ref={bottomRef} />
</div>
</div>
);
}
interface ChatTopContextBarProps {
onClearQuotedReply: () => void;
onOpenWorkspacePath: (path: string) => void;
onRemovePendingAttachment: (path: string) => void;
pendingAttachments: string[];
quotedReply: QuotedReply | null;
quotedReplyLabel: string;
workspaceDownloadExtensionSet: ReadonlySet<string>;
}
export function ChatTopContextBar({
onClearQuotedReply,
onOpenWorkspacePath,
onRemovePendingAttachment,
pendingAttachments,
quotedReply,
quotedReplyLabel,
workspaceDownloadExtensionSet,
}: ChatTopContextBarProps) {
if (!quotedReply && pendingAttachments.length === 0) return null;
return (
<div className="ops-chat-top-context">
{quotedReply ? (
<div className="ops-composer-quote" aria-live="polite">
<div className="ops-composer-quote-head">
<span>{quotedReplyLabel}</span>
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={onClearQuotedReply}
>
<X size={12} />
</button>
</div>
<div className="ops-composer-quote-text">{normalizeAssistantMessageText(quotedReply.text)}</div>
</div>
) : null}
{pendingAttachments.length > 0 ? (
<div className="ops-pending-files">
{pendingAttachments.map((path) => (
<span key={path} className="ops-pending-chip mono">
{(() => {
const filePath = normalizeDashboardAttachmentPath(path);
const fileAction = workspaceFileAction(filePath, workspaceDownloadExtensionSet);
const filename = filePath.split('/').pop() || filePath;
return (
<a
className="ops-attach-link mono ops-pending-open"
href="#"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onOpenWorkspacePath(filePath);
}}
>
{fileAction === 'download' ? (
<Download size={12} className="ops-attach-link-icon" />
) : fileAction === 'preview' ? (
<Eye size={12} className="ops-attach-link-icon" />
) : (
<FileText size={12} className="ops-attach-link-icon" />
)}
<span className="ops-attach-link-name">{filename}</span>
</a>
);
})()}
<button
type="button"
className="ops-chat-inline-action ops-no-tip-icon-btn"
onClick={(event) => {
event.preventDefault();
event.stopPropagation();
onRemovePendingAttachment(path);
}}
>
<X size={12} />
</button>
</span>
))}
</div>
) : null}
</div>
);
}
interface ChatDisabledMaskProps {
botDisabledHint: string;
botStarting: string;
botStopping: string;
canChat: boolean;
chatDisabled: string;
selectedBotEnabled: boolean;
selectedBotControlState?: string;
}
export function ChatDisabledMask({
botDisabledHint,
botStarting,
botStopping,
canChat,
chatDisabled,
selectedBotEnabled,
selectedBotControlState,
}: ChatDisabledMaskProps) {
if (canChat) return null;
return (
<div className="ops-chat-disabled-mask">
<div className="ops-chat-disabled-card">
{selectedBotControlState === 'starting'
? botStarting
: selectedBotControlState === 'stopping'
? botStopping
: !selectedBotEnabled
? botDisabledHint
: chatDisabled}
</div>
</div>
);
}

View File

@ -0,0 +1,321 @@
import type { RefObject } from 'react';
import {
Boxes,
Check,
Clock3,
EllipsisVertical,
FileText,
Hammer,
MessageSquareText,
RefreshCw,
RotateCcw,
Save,
Settings2,
SlidersHorizontal,
Trash2,
TriangleAlert,
Waypoints,
X,
} from 'lucide-react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { formatBytes, formatPercent } from '../botDashboardShared';
import type { BotResourceSnapshot } from '../botDashboardShared';
interface RuntimePanelHeaderLabels {
agent: string;
base: string;
channels: string;
clearHistory: string;
cronViewer: string;
envParams: string;
exportHistory: string;
mcp: string;
more: string;
params: string;
restart: string;
runtime: string;
skills: string;
topic: string;
}
interface RuntimePanelHeaderProps {
labels: RuntimePanelHeaderLabels;
menuDisabled: boolean;
menuOpen: boolean;
menuRef: RefObject<HTMLDivElement | null>;
onClearHistory: () => void;
onExportHistory: () => void;
onOpenAgent: () => void;
onOpenBase: () => void;
onOpenChannels: () => void;
onOpenCron: () => void;
onOpenEnvParams: () => void;
onOpenMcp: () => void;
onOpenParams: () => void;
onOpenSkills: () => void;
onOpenTopic: () => void;
onRestart: () => void;
onToggleMenu: () => void;
restartDisabled: boolean;
restartLoading: boolean;
}
export function RuntimePanelHeader({
labels,
menuDisabled,
menuOpen,
menuRef,
onClearHistory,
onExportHistory,
onOpenAgent,
onOpenBase,
onOpenChannels,
onOpenCron,
onOpenEnvParams,
onOpenMcp,
onOpenParams,
onOpenSkills,
onOpenTopic,
onRestart,
onToggleMenu,
restartDisabled,
restartLoading,
}: RuntimePanelHeaderProps) {
return (
<div className="row-between ops-runtime-head">
<h2 style={{ fontSize: 18 }}>{labels.runtime}</h2>
<div className="ops-panel-tools" ref={menuRef}>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onRestart}
disabled={restartDisabled}
tooltip={labels.restart}
aria-label={labels.restart}
>
<RotateCcw size={14} className={restartLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onToggleMenu}
disabled={menuDisabled}
tooltip={labels.more}
aria-label={labels.more}
aria-haspopup="menu"
aria-expanded={menuOpen}
>
<EllipsisVertical size={14} />
</LucentIconButton>
{menuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={labels.more}>
<button className="ops-more-item" role="menuitem" onClick={onOpenBase}>
<Settings2 size={14} />
<span>{labels.base}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenParams}>
<SlidersHorizontal size={14} />
<span>{labels.params}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenChannels}>
<Waypoints size={14} />
<span>{labels.channels}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenTopic}>
<MessageSquareText size={14} />
<span>{labels.topic}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenEnvParams}>
<Settings2 size={14} />
<span>{labels.envParams}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenSkills}>
<Hammer size={14} />
<span>{labels.skills}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenMcp}>
<Boxes size={14} />
<span>{labels.mcp}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenCron}>
<Clock3 size={14} />
<span>{labels.cronViewer}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onOpenAgent}>
<FileText size={14} />
<span>{labels.agent}</span>
</button>
<button className="ops-more-item" role="menuitem" onClick={onExportHistory}>
<Save size={14} />
<span>{labels.exportHistory}</span>
</button>
<button className="ops-more-item danger" role="menuitem" onClick={onClearHistory}>
<Trash2 size={14} />
<span>{labels.clearHistory}</span>
</button>
</div>
) : null}
</div>
</div>
);
}
interface RuntimeStateCardProps {
displayState: string;
modelName: string;
}
export function RuntimeStateCard({ displayState, modelName }: RuntimeStateCardProps) {
return (
<div className="card ops-runtime-card ops-runtime-state-card is-visual">
<div className={`ops-state-stage ops-state-${String(displayState).toLowerCase()}`}>
<div className="ops-state-model mono">{modelName || '-'}</div>
<div className="ops-state-face" aria-hidden="true">
<span className="ops-state-eye left" />
<span className="ops-state-eye right" />
</div>
<div className="ops-state-caption mono">{String(displayState || 'IDLE').toUpperCase()}</div>
{displayState === 'TOOL_CALL' ? <Hammer size={18} className="ops-state-float state-tool" /> : null}
{displayState === 'SUCCESS' ? <Check size={18} className="ops-state-float state-success" /> : null}
{displayState === 'ERROR' ? <TriangleAlert size={18} className="ops-state-float state-error" /> : null}
</div>
</div>
);
}
interface ResourceMonitorLabels {
baseImage: string;
blockIo: string;
close: string;
collected: string;
configuredLimits: string;
container: string;
containerName: string;
cpu: string;
dockerRuntimeLimits: string;
liveUsage: string;
loading: string;
memory: string;
memoryPercent: string;
noMetrics: string;
policy: string;
providerModel: string;
refreshNow: string;
resourceMonitor: string;
sizeUnlimited: string;
storage: string;
uiRuleHint: string;
workspaceUsage: string;
workspacePercent: string;
networkIo: string;
}
interface ResourceMonitorModalProps {
botId: string;
botImageTag: string;
botName: string;
botProvider: string;
botModel: string;
error: string;
isOpen: boolean;
labels: ResourceMonitorLabels;
loading: boolean;
onClose: () => void;
onRefresh: () => void;
snapshot: BotResourceSnapshot | null;
}
export function ResourceMonitorModal({
botId,
botImageTag,
botModel,
botName,
botProvider,
error,
isOpen,
labels,
loading,
onClose,
onRefresh,
snapshot,
}: ResourceMonitorModalProps) {
if (!isOpen) return null;
return (
<div className="modal-mask" onClick={onClose}>
<div className="modal-card modal-wide" onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row modal-title-with-close">
<div className="modal-title-main">
<h3>{labels.resourceMonitor}</h3>
<span className="modal-sub mono">{botName || botId}</span>
</div>
<div className="modal-title-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onRefresh}
tooltip={labels.refreshNow}
aria-label={labels.refreshNow}
>
<RefreshCw size={14} className={loading ? 'animate-spin' : ''} />
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={onClose}
tooltip={labels.close}
aria-label={labels.close}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
{error ? <div className="card">{error}</div> : null}
{snapshot ? (
<div className="stack">
<div className="card summary-grid">
<div>{labels.container}: <strong className="mono">{snapshot.docker_status}</strong></div>
<div>{labels.containerName}: <span className="mono">{snapshot.bot_id ? `worker_${snapshot.bot_id}` : '-'}</span></div>
<div>{labels.baseImage}: <span className="mono">{botImageTag || '-'}</span></div>
<div>{labels.providerModel}: <span className="mono">{botProvider || '-'} / {botModel || '-'}</span></div>
<div>{labels.collected}: <span className="mono">{snapshot.collected_at}</span></div>
<div>{labels.policy}: <strong>{labels.sizeUnlimited}</strong></div>
</div>
<div className="grid-2" style={{ gridTemplateColumns: '1fr 1fr' }}>
<div className="card stack">
<div className="section-mini-title">{labels.configuredLimits}</div>
<div className="ops-runtime-row"><span>{labels.cpu}</span><strong>{Number(snapshot.configured.cpu_cores) === 0 ? labels.sizeUnlimited : snapshot.configured.cpu_cores}</strong></div>
<div className="ops-runtime-row"><span>{labels.memory}</span><strong>{Number(snapshot.configured.memory_mb) === 0 ? labels.sizeUnlimited : `${snapshot.configured.memory_mb} MB`}</strong></div>
<div className="ops-runtime-row"><span>{labels.storage}</span><strong>{Number(snapshot.configured.storage_gb) === 0 ? labels.sizeUnlimited : `${snapshot.configured.storage_gb} GB`}</strong></div>
</div>
<div className="card stack">
<div className="section-mini-title">{labels.dockerRuntimeLimits}</div>
<div className="ops-runtime-row"><span>{labels.cpu}</span><strong>{snapshot.runtime.limits.cpu_cores ? snapshot.runtime.limits.cpu_cores.toFixed(2) : (Number(snapshot.configured.cpu_cores) === 0 ? labels.sizeUnlimited : '-')}</strong></div>
<div className="ops-runtime-row"><span>{labels.memory}</span><strong>{snapshot.runtime.limits.memory_bytes ? formatBytes(snapshot.runtime.limits.memory_bytes) : (Number(snapshot.configured.memory_mb) === 0 ? labels.sizeUnlimited : '-')}</strong></div>
<div className="ops-runtime-row"><span>{labels.storage}</span><strong>{snapshot.runtime.limits.storage_bytes ? formatBytes(snapshot.runtime.limits.storage_bytes) : (snapshot.runtime.limits.storage_opt_raw || (Number(snapshot.configured.storage_gb) === 0 ? labels.sizeUnlimited : '-'))}</strong></div>
</div>
</div>
<div className="card stack">
<div className="section-mini-title">{labels.liveUsage}</div>
<div className="ops-runtime-row"><span>{labels.cpu}</span><strong>{formatPercent(snapshot.runtime.usage.cpu_percent)}</strong></div>
<div className="ops-runtime-row"><span>{labels.memory}</span><strong>{formatBytes(snapshot.runtime.usage.memory_bytes)} / {snapshot.runtime.usage.memory_limit_bytes > 0 ? formatBytes(snapshot.runtime.usage.memory_limit_bytes) : '-'}</strong></div>
<div className="ops-runtime-row"><span>{labels.memoryPercent}</span><strong>{formatPercent(snapshot.runtime.usage.memory_percent)}</strong></div>
<div className="ops-runtime-row"><span>{labels.workspaceUsage}</span><strong>{formatBytes(snapshot.workspace.usage_bytes)} / {snapshot.workspace.configured_limit_bytes ? formatBytes(snapshot.workspace.configured_limit_bytes) : '-'}</strong></div>
<div className="ops-runtime-row"><span>{labels.workspacePercent}</span><strong>{formatPercent(snapshot.workspace.usage_percent)}</strong></div>
<div className="ops-runtime-row"><span>{labels.networkIo}</span><strong>RX {formatBytes(snapshot.runtime.usage.network_rx_bytes)} · TX {formatBytes(snapshot.runtime.usage.network_tx_bytes)}</strong></div>
<div className="ops-runtime-row"><span>{labels.blockIo}</span><strong>R {formatBytes(snapshot.runtime.usage.blk_read_bytes)} · W {formatBytes(snapshot.runtime.usage.blk_write_bytes)}</strong></div>
<div className="ops-runtime-row"><span>PIDs</span><strong>{snapshot.runtime.usage.pids || 0}</strong></div>
</div>
<div className="field-label">
{snapshot.note}
{labels.uiRuleHint}
</div>
</div>
) : (
<div className="ops-empty-inline">{loading ? labels.loading : labels.noMetrics}</div>
)}
</div>
</div>
);
}

View File

@ -0,0 +1,506 @@
import { Copy, FileText, FolderOpen, Maximize2, Minimize2, RefreshCw, Save, Search, X } from 'lucide-react';
import type { ReactNode } from 'react';
import { LucentIconButton } from '../../../components/lucent/LucentIconButton';
import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor';
import { WorkspaceMarkdownView } from '../shared/workspaceMarkdown';
import {
formatBytes,
formatWorkspaceTime,
isPreviewableWorkspaceFile,
renderWorkspacePathSegments,
workspaceFileAction,
} from '../botDashboardShared';
import type { WorkspaceHoverCardState, WorkspaceNode, WorkspacePreviewState } from '../botDashboardShared';
interface WorkspaceExplorerLabels {
autoRefresh: string;
clearSearch: string;
download: string;
emptyDir: string;
fileNotPreviewable: string;
folder: string;
goUp: string;
goUpTitle: string;
loadingDir: string;
noPreviewFile: string;
openFolderTitle: string;
openingPreview: string;
previewTitle: string;
refreshHint: string;
searchAction: string;
workspaceHint: string;
workspaceSearchNoResult: string;
workspaceSearchPlaceholder: string;
workspaceTitle: string;
}
interface WorkspaceExplorerPanelProps {
entries: WorkspaceNode[];
labels: WorkspaceExplorerLabels;
normalizedWorkspaceQuery: string;
onHoverNode: (node: WorkspaceNode, anchor: HTMLElement) => void;
onLeaveNode: () => void;
onOpenFile: (path: string) => void;
onOpenFolder: (path: string) => void;
onQueryChange: (value: string) => void;
onRefresh: () => void;
onToggleAutoRefresh: () => void;
query: string;
queryInputName: string;
showParentEntry: boolean;
showPreviewFileHint: boolean;
workspaceAutoRefresh: boolean;
workspaceError: string;
workspaceFileLoading: boolean;
workspaceLoading: boolean;
workspaceParentPath: string | null;
workspacePathDisplay: string;
workspaceSearchLoading: boolean;
workspaceDownloadExtensionSet: ReadonlySet<string>;
}
export function WorkspaceExplorerPanel({
entries,
labels,
normalizedWorkspaceQuery,
onHoverNode,
onLeaveNode,
onOpenFile,
onOpenFolder,
onQueryChange,
onRefresh,
onToggleAutoRefresh,
query,
queryInputName,
showParentEntry,
showPreviewFileHint,
workspaceAutoRefresh,
workspaceError,
workspaceFileLoading,
workspaceLoading,
workspaceParentPath,
workspacePathDisplay,
workspaceSearchLoading,
workspaceDownloadExtensionSet,
}: WorkspaceExplorerPanelProps) {
const renderedNodes: ReactNode[] = [];
if (showParentEntry) {
renderedNodes.push(
<button
key="dir:.."
className="workspace-entry dir nav-up"
onClick={() => onOpenFolder(workspaceParentPath || '')}
title={labels.goUpTitle}
>
<FolderOpen size={14} />
<span className="workspace-entry-name">..</span>
<span className="workspace-entry-meta">{labels.goUp}</span>
</button>,
);
}
entries.forEach((node) => {
const key = `${node.type}:${node.path}`;
if (node.type === 'dir') {
renderedNodes.push(
<button
key={key}
className="workspace-entry dir"
onClick={() => onOpenFolder(node.path)}
title={labels.openFolderTitle}
>
<FolderOpen size={14} />
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta">{labels.folder}</span>
</button>,
);
return;
}
const previewable = isPreviewableWorkspaceFile(node, workspaceDownloadExtensionSet);
const downloadOnlyFile = workspaceFileAction(node.path, workspaceDownloadExtensionSet) === 'download';
renderedNodes.push(
<button
key={key}
className={`workspace-entry file ${previewable ? '' : 'disabled'}`}
disabled={workspaceFileLoading}
aria-disabled={!previewable || workspaceFileLoading}
onClick={() => {
if (workspaceFileLoading || !previewable) return;
onOpenFile(node.path);
}}
onMouseEnter={(event) => onHoverNode(node, event.currentTarget)}
onMouseLeave={onLeaveNode}
onFocus={(event) => onHoverNode(node, event.currentTarget)}
onBlur={onLeaveNode}
title={previewable ? (downloadOnlyFile ? labels.download : labels.previewTitle) : labels.fileNotPreviewable}
>
<FileText size={14} />
<span className="workspace-entry-name" title={node.name}>{node.name}</span>
<span className="workspace-entry-meta mono">{node.ext || '-'}</span>
</button>,
);
});
return (
<div className="card ops-runtime-card">
<div className="section-mini-title">{labels.workspaceTitle}</div>
{workspaceError ? <div className="ops-empty-inline">{workspaceError}</div> : null}
<div className="workspace-toolbar">
<div className="workspace-path-wrap">
<div className="workspace-path mono" title={workspacePathDisplay}>
{workspacePathDisplay}
</div>
</div>
<div className="workspace-toolbar-actions">
<LucentIconButton
className="workspace-refresh-icon-btn"
disabled={workspaceLoading}
onClick={onRefresh}
tooltip={labels.refreshHint}
aria-label={labels.refreshHint}
>
<RefreshCw size={14} className={workspaceLoading ? 'animate-spin' : ''} />
</LucentIconButton>
<label className="workspace-auto-switch" title={labels.autoRefresh}>
<span className="workspace-auto-switch-label">{labels.autoRefresh}</span>
<input
type="checkbox"
checked={workspaceAutoRefresh}
onChange={onToggleAutoRefresh}
aria-label={labels.autoRefresh}
/>
<span className="workspace-auto-switch-track" />
</label>
</div>
</div>
<div className="workspace-search-toolbar">
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={query}
onChange={(event) => onQueryChange(event.target.value)}
placeholder={labels.workspaceSearchPlaceholder}
aria-label={labels.workspaceSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
name={queryInputName}
id={queryInputName}
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<button
type="button"
className="ops-search-inline-btn"
onClick={() => {
if (query.trim()) {
onQueryChange('');
return;
}
onQueryChange(query.trim());
}}
title={query.trim() ? labels.clearSearch : labels.searchAction}
aria-label={query.trim() ? labels.clearSearch : labels.searchAction}
>
{query.trim() ? <X size={14} /> : <Search size={14} />}
</button>
</div>
</div>
<div className="workspace-panel">
<div className="workspace-list">
{workspaceLoading || workspaceSearchLoading ? (
<div className="ops-empty-inline">{labels.loadingDir}</div>
) : renderedNodes.length === 0 ? (
<div className="ops-empty-inline">{normalizedWorkspaceQuery ? labels.workspaceSearchNoResult : labels.emptyDir}</div>
) : (
renderedNodes
)}
</div>
<div className="workspace-hint">
{workspaceFileLoading
? labels.openingPreview
: labels.workspaceHint}
</div>
</div>
{showPreviewFileHint ? (
<div className="ops-empty-inline">{labels.noPreviewFile}</div>
) : null}
</div>
);
}
interface WorkspacePreviewModalProps {
buildWorkspaceDownloadHref: (path: string, forceDownload?: boolean) => string;
buildWorkspaceRawHref: (path: string, forceDownload?: boolean) => string;
closeLabel: string;
closePreview: () => void;
copyAddressLabel: string;
copyPathLabel: string;
downloadLabel: string;
editFileLabel: string;
filePreviewLabel: string;
fileTruncatedLabel: string;
fullscreenEditLabel: string;
fullscreenExitLabel: string;
fullscreenPreviewLabel: string;
isZh: boolean;
onCopyPreviewPath: (path: string) => void;
onCopyPreviewUrl: (path: string) => void;
onOpenWorkspacePath: (path: string) => void;
onSaveShortcut: () => void;
preview: WorkspacePreviewState;
previewCanEdit: boolean;
previewDraft: string;
previewEditorEnabled: boolean;
previewFullscreen: boolean;
previewSaving: boolean;
resolveMediaSrc: (src: string, baseFilePath?: string) => string;
saveLabel: string;
setPreviewDraft: (value: string) => void;
setPreviewFullscreen: (value: boolean | ((prev: boolean) => boolean)) => void;
setPreviewMode: (mode: 'preview' | 'edit') => void;
}
export function WorkspacePreviewModal({
buildWorkspaceDownloadHref,
buildWorkspaceRawHref,
closeLabel,
closePreview,
copyAddressLabel,
copyPathLabel,
downloadLabel,
editFileLabel,
filePreviewLabel,
fileTruncatedLabel,
fullscreenEditLabel,
fullscreenExitLabel,
fullscreenPreviewLabel,
onCopyPreviewPath,
onCopyPreviewUrl,
onOpenWorkspacePath,
onSaveShortcut,
preview,
previewCanEdit,
previewDraft,
previewEditorEnabled,
previewFullscreen,
previewSaving,
resolveMediaSrc,
saveLabel,
setPreviewDraft,
setPreviewFullscreen,
setPreviewMode,
}: WorkspacePreviewModalProps) {
return (
<div className="modal-mask" onClick={closePreview}>
<div className={`modal-card modal-preview ${previewFullscreen ? 'modal-preview-fullscreen' : ''}`} onClick={(e) => e.stopPropagation()}>
<div className="modal-title-row workspace-preview-header">
<div className="workspace-preview-header-text">
<h3>{previewEditorEnabled ? editFileLabel : filePreviewLabel}</h3>
<span className="modal-sub mono workspace-preview-path-row">
<span className="workspace-path-segments" title={preview.path}>
{renderWorkspacePathSegments(preview.path, 'preview-path')}
</span>
<LucentIconButton
className="workspace-preview-copy-name"
onClick={() => void onCopyPreviewPath(preview.path)}
tooltip={copyPathLabel}
aria-label={copyPathLabel}
>
<Copy size={12} />
</LucentIconButton>
</span>
</div>
<div className="workspace-preview-header-actions">
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setPreviewFullscreen((v) => !v)}
tooltip={
previewFullscreen
? fullscreenExitLabel
: previewEditorEnabled
? fullscreenEditLabel
: fullscreenPreviewLabel
}
aria-label={
previewFullscreen
? fullscreenExitLabel
: previewEditorEnabled
? fullscreenEditLabel
: fullscreenPreviewLabel
}
>
{previewFullscreen ? <Minimize2 size={14} /> : <Maximize2 size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={closePreview}
tooltip={closeLabel}
aria-label={closeLabel}
>
<X size={14} />
</LucentIconButton>
</div>
</div>
<div
className={`workspace-preview-body ${preview.isMarkdown ? 'markdown' : ''} ${previewEditorEnabled ? 'is-editing' : ''} ${preview.isImage || preview.isVideo || preview.isAudio ? 'media' : ''}`}
>
{preview.isImage ? (
<img
className="workspace-preview-image"
src={buildWorkspaceDownloadHref(preview.path, false)}
alt={preview.path.split('/').pop() || 'workspace-image'}
/>
) : preview.isVideo ? (
<video
className="workspace-preview-media"
src={buildWorkspaceDownloadHref(preview.path, false)}
controls
preload="metadata"
/>
) : preview.isAudio ? (
<audio
className="workspace-preview-audio"
src={buildWorkspaceDownloadHref(preview.path, false)}
controls
preload="metadata"
/>
) : preview.isHtml ? (
<iframe
className="workspace-preview-embed"
src={buildWorkspaceRawHref(preview.path, false)}
title={preview.path}
/>
) : previewEditorEnabled ? (
<MarkdownLiteEditor
className="workspace-preview-editor-shell"
textareaClassName="workspace-preview-editor"
value={previewDraft}
onChange={setPreviewDraft}
spellCheck={false}
fullHeight
onSaveShortcut={onSaveShortcut}
/>
) : preview.isMarkdown ? (
<WorkspaceMarkdownView
className="workspace-markdown"
content={preview.content || ''}
openWorkspacePath={(path) => {
void onOpenWorkspacePath(path);
}}
resolveMediaSrc={resolveMediaSrc}
/>
) : (
<pre>{preview.content}</pre>
)}
</div>
{preview.truncated ? (
<div className="ops-empty-inline">{fileTruncatedLabel}</div>
) : null}
<div className="row-between">
<span className="workspace-preview-meta mono">{preview.ext || '-'}</span>
<div style={{ display: 'inline-flex', alignItems: 'center', gap: 8 }}>
{previewEditorEnabled ? (
<>
<button
className="btn btn-secondary"
onClick={() => {
setPreviewDraft(preview.content || '');
setPreviewMode('preview');
}}
disabled={previewSaving}
>
{closeLabel}
</button>
<button
className="btn btn-primary"
onClick={onSaveShortcut}
disabled={previewSaving}
>
{previewSaving ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{saveLabel}</span>
</button>
</>
) : previewCanEdit ? (
<button
className="btn btn-secondary"
onClick={() => setPreviewMode('edit')}
>
{editFileLabel}
</button>
) : null}
{preview.isHtml ? (
<button
className="btn btn-secondary"
onClick={() => void onCopyPreviewUrl(preview.path)}
>
{copyAddressLabel}
</button>
) : (
<a
className="btn btn-secondary"
href={buildWorkspaceDownloadHref(preview.path, true)}
target="_blank"
rel="noopener noreferrer"
download={preview.path.split('/').pop() || 'workspace-file'}
>
{downloadLabel}
</a>
)}
</div>
</div>
</div>
</div>
);
}
interface WorkspaceHoverTooltipProps {
hoverCard: WorkspaceHoverCardState;
isZh: boolean;
}
export function WorkspaceHoverTooltip({ hoverCard, isZh }: WorkspaceHoverTooltipProps) {
return (
<div
className={`workspace-hover-panel ${hoverCard.above ? 'is-above' : ''}`}
style={{ top: hoverCard.top, left: hoverCard.left }}
role="tooltip"
>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '全称' : 'Name'}</span>
<span className="workspace-entry-info-value mono">{hoverCard.node.name || '-'}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '完整路径' : 'Full Path'}</span>
<span
className="workspace-entry-info-value workspace-entry-info-path mono"
title={`/root/.nanobot/workspace/${String(hoverCard.node.path || '').replace(/^\/+/, '')}`}
>
{renderWorkspacePathSegments(
`/root/.nanobot/workspace/${String(hoverCard.node.path || '').replace(/^\/+/, '')}`,
'hover-path',
)}
</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '创建时间' : 'Created'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(hoverCard.node.ctime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '修改时间' : 'Modified'}</span>
<span className="workspace-entry-info-value">{formatWorkspaceTime(hoverCard.node.mtime, isZh)}</span>
</div>
<div className="workspace-entry-info-row">
<span className="workspace-entry-info-label">{isZh ? '文件大小' : 'Size'}</span>
<span className="workspace-entry-info-value mono">{Number.isFinite(Number(hoverCard.node.size)) ? formatBytes(Number(hoverCard.node.size)) : '-'}</span>
</div>
</div>
);
}

View File

@ -0,0 +1,594 @@
import type { ReactNode } from 'react';
import { ChevronDown, ChevronUp, RefreshCw, Save, Trash2, X } from 'lucide-react';
import { LucentIconButton } from '../../../../components/lucent/LucentIconButton';
import type { BotChannel, BotTopic } from '../../botDashboardShared';
import { normalizeRoutingTextList } from './shared';
interface ChannelDrawerLabels {
addChannel: string;
cancel: string;
channelConfigured: string;
channelEmpty: string;
channelPending: string;
channelType: string;
customChannel: string;
disabled: string;
enabled: string;
globalDeliveryDesc: string;
globalDeliveryTitle: string;
remove: string;
saveChannel: string;
sendProgress: string;
sendToolHints: string;
collapse: string;
expand: string;
}
interface ChannelDrawerContentProps {
channels: BotChannel[];
expandedChannelByKey: Record<string, boolean>;
globalDelivery: {
sendProgress?: boolean;
sendToolHints?: boolean;
};
isSavingChannel: boolean;
isSavingGlobalDelivery: boolean;
labels: ChannelDrawerLabels;
newChannelDraft: BotChannel;
newChannelPanelOpen: boolean;
onCancelNewChannel: () => void;
onSaveChannel: (channel: BotChannel) => void;
onSaveGlobalDelivery: () => void;
onToggleChannelExpanded: (uiKey: string, expanded: boolean) => void;
onUpdateChannelLocal: (index: number, patch: Partial<BotChannel>) => void;
onUpdateGlobalDeliveryFlag: (key: 'sendProgress' | 'sendToolHints', value: boolean) => void;
onUpdateNewChannelDraft: (patch: Partial<BotChannel>) => void;
onRemoveChannel: (channel: BotChannel) => void;
renderChannelFields: (channel: BotChannel, onPatch: (patch: Partial<BotChannel>) => void) => ReactNode;
resolveChannelHasCredential: (channel: BotChannel) => boolean;
resolveChannelUiKey: (channel: BotChannel, index: number) => string;
shouldHideChannel: (channel: BotChannel) => boolean;
}
export function ChannelDrawerContent({
channels,
expandedChannelByKey,
globalDelivery,
isSavingChannel,
isSavingGlobalDelivery,
labels,
newChannelDraft,
newChannelPanelOpen,
onCancelNewChannel,
onSaveChannel,
onSaveGlobalDelivery,
onToggleChannelExpanded,
onUpdateChannelLocal,
onUpdateGlobalDeliveryFlag,
onUpdateNewChannelDraft,
onRemoveChannel,
renderChannelFields,
resolveChannelHasCredential,
resolveChannelUiKey,
shouldHideChannel,
}: ChannelDrawerContentProps) {
const visibleChannels = channels.filter((channel) => !shouldHideChannel(channel));
return (
<div className="ops-bot-panel-drawer-shell ops-config-modal">
<div className="card">
<div className="section-mini-title">{labels.globalDeliveryTitle}</div>
<div className="field-label">{labels.globalDeliveryDesc}</div>
<div className="wizard-dashboard-switches" style={{ marginTop: 8 }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(globalDelivery.sendProgress)}
onChange={(event) => onUpdateGlobalDeliveryFlag('sendProgress', event.target.checked)}
style={{ marginRight: 6 }}
/>
{labels.sendProgress}
</label>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(globalDelivery.sendToolHints)}
onChange={(event) => onUpdateGlobalDeliveryFlag('sendToolHints', event.target.checked)}
style={{ marginRight: 6 }}
/>
{labels.sendToolHints}
</label>
<LucentIconButton
className="btn btn-primary btn-sm icon-btn"
disabled={isSavingGlobalDelivery}
onClick={onSaveGlobalDelivery}
tooltip={labels.saveChannel}
aria-label={labels.saveChannel}
>
<Save size={14} />
</LucentIconButton>
</div>
</div>
<div className="wizard-channel-list ops-config-list-scroll">
{visibleChannels.length === 0 ? (
<div className="ops-empty-inline">{labels.channelEmpty}</div>
) : (
channels.map((channel, index) => {
if (shouldHideChannel(channel)) return null;
const uiKey = resolveChannelUiKey(channel, index);
const expanded = expandedChannelByKey[uiKey] ?? index === 0;
const hasCredential = resolveChannelHasCredential(channel);
const summary = [
String(channel.channel_type || '').toUpperCase(),
channel.is_active ? labels.enabled : labels.disabled,
hasCredential ? labels.channelConfigured : labels.channelPending,
].join(' · ');
return (
<div key={`${channel.id}-${channel.channel_type}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{String(channel.channel_type || '').toUpperCase()}</strong>
<div className="ops-config-collapsed-meta">{summary}</div>
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={channel.is_active}
onChange={(event) => onUpdateChannelLocal(index, { is_active: event.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.enabled}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingChannel}
onClick={() => onRemoveChannel(channel)}
tooltip={labels.remove}
aria-label={labels.remove}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onToggleChannelExpanded(uiKey, expanded)}
tooltip={expanded ? labels.collapse : labels.expand}
aria-label={expanded ? labels.collapse : labels.expand}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
</div>
{expanded ? (
<>
<div className="ops-topic-grid">
{renderChannelFields(channel, (patch) => onUpdateChannelLocal(index, patch))}
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.customChannel}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingChannel} onClick={() => onSaveChannel(channel)}>
<Save size={14} />
<span style={{ marginLeft: 6 }}>{labels.saveChannel}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{newChannelPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{labels.addChannel}</strong>
<div className="ops-config-collapsed-meta">{String(newChannelDraft.channel_type || '').toUpperCase()}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton className="ops-plain-icon-btn" onClick={onCancelNewChannel} tooltip={labels.cancel} aria-label={labels.cancel}>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.channelType}</label>
<input className="input mono" value={String(newChannelDraft.channel_type || '').toUpperCase()} readOnly />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label" style={{ visibility: 'hidden' }}>
{labels.enabled}
</label>
<label className="field-label">
<input
type="checkbox"
checked={newChannelDraft.is_active}
onChange={(event) => onUpdateNewChannelDraft({ is_active: event.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.enabled}
</label>
</div>
{renderChannelFields(newChannelDraft, onUpdateNewChannelDraft)}
</div>
</div>
) : null}
</div>
);
}
interface TopicDrawerLabels {
cancel: string;
collapse: string;
delete: string;
expand: string;
hideAdvanced: string;
includeSummary: string;
excludeSummary: string;
save: string;
showAdvanced: string;
topicActive: string;
topicAdd: string;
topicAddHint: string;
topicDescription: string;
topicEmpty: string;
topicExamplesNegative: string;
topicExamplesPositive: string;
topicExcludeWhen: string;
topicIncludeWhen: string;
topicKey: string;
topicKeyPlaceholder: string;
topicListHint: string;
topicName: string;
topicPriority: string;
topicPurpose: string;
}
interface TopicDrawerContentProps {
expandedTopicByKey: Record<string, boolean>;
isSavingTopic: boolean;
labels: TopicDrawerLabels;
newTopicAdvancedOpen: boolean;
newTopicDescription: string;
newTopicExamplesNegative: string;
newTopicExamplesPositive: string;
newTopicExcludeWhen: string;
newTopicIncludeWhen: string;
newTopicKey: string;
newTopicName: string;
newTopicPanelOpen: boolean;
newTopicPriority: string;
newTopicPurpose: string;
newTopicSourceLabel: string;
onCancelNewTopic: () => void;
onChangeNewTopicDescription: (value: string) => void;
onChangeNewTopicExamplesNegative: (value: string) => void;
onChangeNewTopicExamplesPositive: (value: string) => void;
onChangeNewTopicExcludeWhen: (value: string) => void;
onChangeNewTopicIncludeWhen: (value: string) => void;
onChangeNewTopicKey: (value: string) => void;
onChangeNewTopicName: (value: string) => void;
onChangeNewTopicPriority: (value: string) => void;
onChangeNewTopicPurpose: (value: string) => void;
onRemoveTopic: (topic: BotTopic) => void;
onSaveTopic: (topic: BotTopic) => void;
onToggleNewTopicAdvanced: () => void;
onToggleTopicExpanded: (uiKey: string, expanded: boolean) => void;
onUpdateTopicLocal: (index: number, patch: Partial<BotTopic>) => void;
resolveTopicUiKey: (topic: BotTopic, index: number) => string;
topics: BotTopic[];
}
export function TopicDrawerContent({
expandedTopicByKey,
isSavingTopic,
labels,
newTopicAdvancedOpen,
newTopicDescription,
newTopicExamplesNegative,
newTopicExamplesPositive,
newTopicExcludeWhen,
newTopicIncludeWhen,
newTopicKey,
newTopicName,
newTopicPanelOpen,
newTopicPriority,
newTopicPurpose,
newTopicSourceLabel,
onCancelNewTopic,
onChangeNewTopicDescription,
onChangeNewTopicExamplesNegative,
onChangeNewTopicExamplesPositive,
onChangeNewTopicExcludeWhen,
onChangeNewTopicIncludeWhen,
onChangeNewTopicKey,
onChangeNewTopicName,
onChangeNewTopicPriority,
onChangeNewTopicPurpose,
onRemoveTopic,
onSaveTopic,
onToggleNewTopicAdvanced,
onToggleTopicExpanded,
onUpdateTopicLocal,
resolveTopicUiKey,
topics,
}: TopicDrawerContentProps) {
return (
<div className="ops-bot-panel-drawer-shell ops-config-modal">
<div className="wizard-channel-list ops-config-list-scroll">
{topics.length === 0 ? (
<div className="ops-empty-inline">{labels.topicEmpty}</div>
) : (
topics.map((topic, index) => {
const uiKey = resolveTopicUiKey(topic, index);
const expanded = expandedTopicByKey[uiKey] ?? index === 0;
const includeCount = normalizeRoutingTextList(String(topic.routing_include_when || '')).length;
const excludeCount = normalizeRoutingTextList(String(topic.routing_exclude_when || '')).length;
return (
<div key={`${topic.id}-${topic.topic_key}`} className="card wizard-channel-card wizard-channel-compact">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong className="mono">{topic.topic_key}</strong>
<div className="field-label">{topic.name || topic.topic_key}</div>
{!expanded ? (
<div className="ops-config-collapsed-meta">
{`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${labels.includeSummary} ${includeCount} · ${labels.excludeSummary} ${excludeCount}`}
</div>
) : null}
</div>
<div className="ops-config-card-actions">
<label className="field-label">
<input
type="checkbox"
checked={Boolean(topic.is_active)}
onChange={(event) => onUpdateTopicLocal(index, { is_active: event.target.checked })}
style={{ marginRight: 6 }}
/>
{labels.topicActive}
</label>
<LucentIconButton
className="btn btn-danger btn-sm wizard-icon-btn"
disabled={isSavingTopic}
onClick={() => onRemoveTopic(topic)}
tooltip={labels.delete}
aria-label={labels.delete}
>
<Trash2 size={14} />
</LucentIconButton>
<LucentIconButton
className="ops-plain-icon-btn"
onClick={() => onToggleTopicExpanded(uiKey, expanded)}
tooltip={expanded ? labels.collapse : labels.expand}
aria-label={expanded ? labels.collapse : labels.expand}
>
{expanded ? <ChevronUp size={14} /> : <ChevronDown size={14} />}
</LucentIconButton>
</div>
</div>
{expanded ? (
<>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.topicName}</label>
<input
className="input"
value={topic.name || ''}
onChange={(event) => onUpdateTopicLocal(index, { name: event.target.value })}
placeholder={labels.topicName}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicPriority}</label>
<input
className="input mono"
type="number"
min={0}
max={100}
step={1}
value={topic.routing_priority || '50'}
onChange={(event) => onUpdateTopicLocal(index, { routing_priority: event.target.value })}
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicDescription}</label>
<textarea
className="input"
rows={3}
value={topic.description || ''}
onChange={(event) => onUpdateTopicLocal(index, { description: event.target.value })}
placeholder={labels.topicDescription}
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicPurpose}</label>
<textarea
className="input"
rows={3}
value={topic.routing_purpose || ''}
onChange={(event) => onUpdateTopicLocal(index, { routing_purpose: event.target.value })}
placeholder={labels.topicPurpose}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicIncludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_include_when || ''}
onChange={(event) => onUpdateTopicLocal(index, { routing_include_when: event.target.value })}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExcludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_exclude_when || ''}
onChange={(event) => onUpdateTopicLocal(index, { routing_exclude_when: event.target.value })}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesPositive}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_examples_positive || ''}
onChange={(event) => onUpdateTopicLocal(index, { routing_examples_positive: event.target.value })}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesNegative}</label>
<textarea
className="input mono"
rows={4}
value={topic.routing_examples_negative || ''}
onChange={(event) => onUpdateTopicLocal(index, { routing_examples_negative: event.target.value })}
placeholder={labels.topicListHint}
/>
</div>
</div>
<div className="row-between ops-config-footer">
<span className="field-label">{labels.topicAddHint}</span>
<button className="btn btn-primary btn-sm" disabled={isSavingTopic} onClick={() => onSaveTopic(topic)}>
{isSavingTopic ? <RefreshCw size={14} className="animate-spin" /> : <Save size={14} />}
<span style={{ marginLeft: 6 }}>{labels.save}</span>
</button>
</div>
</>
) : null}
</div>
);
})
)}
</div>
{newTopicPanelOpen ? (
<div className="card wizard-channel-card wizard-channel-compact ops-config-new-card">
<div className="ops-config-card-header">
<div className="ops-config-card-main">
<strong>{labels.topicAdd}</strong>
<div className="ops-config-collapsed-meta">{newTopicSourceLabel}</div>
</div>
<div className="ops-config-card-actions">
<LucentIconButton
className="ops-plain-icon-btn"
onClick={onToggleNewTopicAdvanced}
tooltip={newTopicAdvancedOpen ? labels.hideAdvanced : labels.showAdvanced}
aria-label={newTopicAdvancedOpen ? labels.hideAdvanced : labels.showAdvanced}
>
{newTopicAdvancedOpen ? <ChevronUp size={15} /> : <ChevronDown size={15} />}
</LucentIconButton>
<LucentIconButton className="ops-plain-icon-btn" onClick={onCancelNewTopic} tooltip={labels.cancel} aria-label={labels.cancel}>
<X size={15} />
</LucentIconButton>
</div>
</div>
<div className="ops-topic-grid">
<div className="ops-config-field">
<label className="field-label">{labels.topicKey}</label>
<input
className="input mono"
value={newTopicKey}
onChange={(event) => onChangeNewTopicKey(event.target.value)}
placeholder={labels.topicKeyPlaceholder}
autoComplete="off"
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicName}</label>
<input
className="input"
value={newTopicName}
onChange={(event) => onChangeNewTopicName(event.target.value)}
placeholder={labels.topicName}
autoComplete="off"
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicDescription}</label>
<textarea
className="input"
rows={3}
value={newTopicDescription}
onChange={(event) => onChangeNewTopicDescription(event.target.value)}
placeholder={labels.topicDescription}
/>
</div>
{newTopicAdvancedOpen ? (
<>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{labels.topicPurpose}</label>
<textarea
className="input"
rows={3}
value={newTopicPurpose}
onChange={(event) => onChangeNewTopicPurpose(event.target.value)}
placeholder={labels.topicPurpose}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicIncludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={newTopicIncludeWhen}
onChange={(event) => onChangeNewTopicIncludeWhen(event.target.value)}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExcludeWhen}</label>
<textarea
className="input mono"
rows={4}
value={newTopicExcludeWhen}
onChange={(event) => onChangeNewTopicExcludeWhen(event.target.value)}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesPositive}</label>
<textarea
className="input mono"
rows={4}
value={newTopicExamplesPositive}
onChange={(event) => onChangeNewTopicExamplesPositive(event.target.value)}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicExamplesNegative}</label>
<textarea
className="input mono"
rows={4}
value={newTopicExamplesNegative}
onChange={(event) => onChangeNewTopicExamplesNegative(event.target.value)}
placeholder={labels.topicListHint}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{labels.topicPriority}</label>
<input
className="input mono"
type="number"
min={0}
max={100}
step={1}
value={newTopicPriority}
onChange={(event) => onChangeNewTopicPriority(event.target.value)}
/>
</div>
</>
) : null}
</div>
</div>
) : null}
</div>
);
}

Some files were not shown because too many files have changed in this diff Show More