From f20dabc58e67fe6eedbcc22918549572296fb6ed Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 27 Mar 2026 00:12:46 +0800 Subject: [PATCH] v0.2.0 --- backend/api/dashboard_assets_router.py | 124 + backend/api/dashboard_bot_admin_router.py | 153 + backend/api/dashboard_bot_io_router.py | 197 + backend/api/dashboard_router.py | 46 + backend/api/dashboard_router_support.py | 20 + backend/api/platform_admin_router.py | 8 + backend/api/platform_node_catalog_router.py | 159 + backend/api/platform_node_probe_router.py | 119 + backend/api/platform_node_resource_router.py | 57 + backend/api/platform_node_support.py | 251 + backend/api/platform_nodes_router.py | 11 + backend/api/platform_overview_router.py | 79 + backend/api/platform_router.py | 697 +- backend/api/platform_settings_router.py | 71 + backend/api/platform_shared.py | 54 + backend/api/sys_router.py | 230 + backend/api/system_runtime_router.py | 29 + backend/app_factory.py | 85 + backend/bootstrap/app_runtime.py | 482 + backend/bootstrap/app_runtime_support.py | 231 + backend/clients/edge/base.py | 21 + backend/clients/edge/http.py | 88 + backend/core/config_manager.py | 1 - backend/core/database.py | 4 + backend/core/docker_manager.py | 6 + backend/core/settings.py | 6 +- backend/core/speech_service.py | 2 +- backend/main.py | 4812 +--------- backend/models/sys_auth.py | 115 + backend/providers/runtime/local.py | 2 +- backend/requirements.txt | 2 + backend/schemas/dashboard.py | 122 + backend/schemas/platform.py | 30 + backend/schemas/sys_auth.py | 153 + backend/services/app_lifecycle_service.py | 165 + backend/services/bot_channel_service.py | 175 + backend/services/bot_config_state_service.py | 320 + backend/services/bot_infra_service.py | 1089 +++ backend/services/bot_lifecycle_service.py | 611 ++ backend/services/bot_message_service.py | 246 + backend/services/bot_query_service.py | 180 + .../services/bot_runtime_snapshot_service.py | 288 + backend/services/dashboard_auth_service.py | 129 + backend/services/image_service.py | 101 + backend/services/platform_activity_service.py | 118 + .../services/platform_analytics_service.py | 104 + backend/services/platform_common.py | 103 + backend/services/platform_overview_service.py | 236 + backend/services/platform_service.py | 1254 +-- backend/services/platform_settings_service.py | 262 + backend/services/platform_settings_support.py | 352 + backend/services/platform_usage_service.py | 230 + backend/services/provider_test_service.py | 86 + backend/services/runtime_event_service.py | 289 + backend/services/runtime_service.py | 36 + backend/services/skill_service.py | 898 ++ .../services/speech_transcription_service.py | 126 + backend/services/sys_auth_service.py | 1215 +++ backend/services/system_service.py | 155 + backend/services/workspace_service.py | 80 +- dashboard-edge/app/api/router.py | 30 + dashboard-edge/app/runtime/docker_manager.py | 6 + dashboard-edge/app/runtime/native_manager.py | 6 + .../app/services/provision_service.py | 1 - .../app/services/state_store_service.py | 30 + .../app/services/workspace_service.py | 44 + design/architecture.md | 1 + design/code-structure-standards.md | 358 + design/refactor-modularization-roadmap.md | 4 + frontend/src/App.css | 3183 +------ frontend/src/App.mobile.css | 530 ++ frontend/src/App.tsx | 521 +- frontend/src/app/AppChrome.tsx | 362 + frontend/src/app/AppShellViews.tsx | 491 + frontend/src/app/appRouteMeta.ts | 115 + .../src/components/lucent/LucentDrawer.tsx | 98 + frontend/src/hooks/useBotsSync.ts | 15 +- frontend/src/i18n/dashboard.en.ts | 6 +- frontend/src/i18n/dashboard.zh-cn.ts | 8 +- frontend/src/i18n/wizard.en.ts | 4 +- frontend/src/i18n/wizard.zh-cn.ts | 4 +- frontend/src/main.tsx | 4 +- frontend/src/modules/bot-home/BotHomePage.tsx | 13 +- .../src/modules/chat/ChatWorkspacePage.tsx | 188 + .../modules/dashboard/BotDashboardModule.css | 146 +- .../modules/dashboard/BotDashboardModule.tsx | 8019 ++--------------- .../dashboard/api/dashboardConfigApi.ts | 126 + .../modules/dashboard/botDashboardShared.tsx | 26 + frontend/src/modules/dashboard/botPanels.ts | 37 + .../components/BotChannelFieldEditor.tsx | 273 + .../components/BotConfigDrawerParts.tsx | 4 + .../components/BotDashboardBotListPanel.tsx | 359 + .../components/BotDashboardConfigDrawers.tsx | 14 + .../BotDashboardConversationNodes.tsx | 275 + .../components/BotDashboardRuntimePanel.tsx | 271 + .../dashboard/components/ChatComposerDock.tsx | 314 + .../dashboard/components/ChatPanelParts.tsx | 217 + .../components/RuntimePanelParts.tsx | 321 + .../components/WorkspacePanelParts.tsx | 506 ++ .../config-drawers/ChannelTopicDrawers.tsx | 594 ++ .../config-drawers/GeneralDrawers.tsx | 511 ++ .../config-drawers/SkillsMcpDrawers.tsx | 391 + .../components/config-drawers/shared.ts | 14 + .../BotDashboardChannelTopicDrawers.tsx | 308 + .../BotDashboardOperationsDrawers.tsx | 348 + .../BotDashboardPrimaryDrawers.tsx | 184 + .../components/dashboard-drawers/types.ts | 217 + .../dashboard/hooks/dashboardChatShared.ts | 145 + .../hooks/useBotDashboardConfigState.ts | 402 + .../hooks/useDashboardChannelConfig.ts | 268 + .../dashboard/hooks/useDashboardChat.ts | 95 + .../hooks/useDashboardChatCommandControl.ts | 312 + .../hooks/useDashboardChatComposer.ts | 148 + .../hooks/useDashboardChatHistory.ts | 385 + .../hooks/useDashboardChatMediaInput.ts | 363 + .../hooks/useDashboardMcpCronConfig.ts | 366 + .../hooks/useDashboardRuntimeControl.ts | 305 + .../hooks/useDashboardSkillsEnvConfig.ts | 254 + .../hooks/useDashboardTopicConfig.ts | 508 ++ .../dashboard/hooks/useDashboardWorkspace.ts | 451 + .../src/modules/dashboard/messageParser.ts | 129 + .../dashboard/shared/botDashboardConstants.ts | 145 + .../shared/botDashboardConversation.ts | 51 + .../shared/botDashboardTopicUtils.ts | 63 + .../dashboard/shared/botDashboardTypes.ts | 301 + .../dashboard/shared/botDashboardUtils.ts | 197 + .../shared/botDashboardWorkspace.tsx | 270 + .../dashboard/shared/workspaceMarkdown.tsx | 62 +- .../modules/onboarding/BotWizardModule.tsx | 1153 +-- .../bot-wizard/BotWizardChannelModal.tsx | 294 + .../bot-wizard/BotWizardStepFour.tsx | 105 + .../bot-wizard/BotWizardStepOne.tsx | 145 + .../bot-wizard/BotWizardStepThree.tsx | 57 + .../bot-wizard/BotWizardStepTwo.tsx | 298 + .../bot-wizard/BotWizardToolsModal.tsx | 121 + .../components/bot-wizard/constants.ts | 111 + .../onboarding/components/bot-wizard/types.ts | 86 + .../onboarding/components/bot-wizard/utils.ts | 28 + .../src/modules/platform/NodeHomePage.tsx | 1028 +-- .../modules/platform/NodeWorkspacePage.tsx | 458 +- .../platform/PlatformDashboardPage.tsx | 1339 +-- .../src/modules/platform/PlatformHomePage.tsx | 358 + .../components/AdminAccessPlaceholderPage.tsx | 36 + .../components/BotWorkspaceBrowser.tsx | 282 + .../components/PlatformSettingsPage.tsx | 382 + .../components/RoleManagementPage.tsx | 521 ++ .../components/SkillMarketManagerModal.tsx | 194 +- .../components/TemplateManagerPage.tsx | 190 + .../components/UserManagementPage.tsx | 496 + .../platform/components/UserProfilePage.tsx | 139 + .../BotWorkspacePreviewModal.tsx | 220 + .../bot-workspace-browser/shared.tsx | 205 + .../useBotWorkspaceBrowser.ts | 388 + .../components/node-home/NodeEditorDrawer.tsx | 314 + .../components/node-home/useNodeHomePage.ts | 427 + .../NodeWorkspaceBotDetailPanel.tsx | 156 + .../NodeWorkspaceBotListPanel.tsx | 203 + .../node-workspace/NodeWorkspaceOverlays.tsx | 395 + .../NodeWorkspaceSummaryCards.tsx | 164 + .../components/node-workspace/types.ts | 65 + .../node-workspace/useNodeWorkspacePage.ts | 595 ++ .../components/node-workspace/utils.ts | 72 + .../PlatformDashboardBotListPanel.tsx | 173 + .../PlatformDashboardOverlays.tsx | 353 + .../PlatformDashboardSections.tsx | 399 + .../components/platform-dashboard/types.ts | 51 + .../usePlatformDashboardPage.ts | 518 ++ .../components/platform-dashboard/utils.ts | 40 + frontend/src/modules/platform/types.ts | 48 + frontend/src/store/appStore.ts | 2 - frontend/src/styles/app-common.css | 1109 +++ frontend/src/styles/app-platform.css | 3188 +++++++ frontend/src/styles/app-shell.css | 888 ++ frontend/src/styles/app-theme.css | 326 + frontend/src/types/bot.ts | 2 - frontend/src/types/sys.ts | 94 + frontend/src/utils/appRoute.ts | 49 +- frontend/src/utils/botAccess.ts | 79 +- frontend/src/utils/panelAccess.ts | 101 - frontend/src/utils/sessionAuth.ts | 96 + 180 files changed, 39841 insertions(+), 20732 deletions(-) create mode 100644 backend/api/dashboard_assets_router.py create mode 100644 backend/api/dashboard_bot_admin_router.py create mode 100644 backend/api/dashboard_bot_io_router.py create mode 100644 backend/api/dashboard_router.py create mode 100644 backend/api/dashboard_router_support.py create mode 100644 backend/api/platform_admin_router.py create mode 100644 backend/api/platform_node_catalog_router.py create mode 100644 backend/api/platform_node_probe_router.py create mode 100644 backend/api/platform_node_resource_router.py create mode 100644 backend/api/platform_node_support.py create mode 100644 backend/api/platform_nodes_router.py create mode 100644 backend/api/platform_overview_router.py create mode 100644 backend/api/platform_settings_router.py create mode 100644 backend/api/platform_shared.py create mode 100644 backend/api/sys_router.py create mode 100644 backend/api/system_runtime_router.py create mode 100644 backend/app_factory.py create mode 100644 backend/bootstrap/app_runtime.py create mode 100644 backend/bootstrap/app_runtime_support.py create mode 100644 backend/models/sys_auth.py create mode 100644 backend/schemas/dashboard.py create mode 100644 backend/schemas/sys_auth.py create mode 100644 backend/services/app_lifecycle_service.py create mode 100644 backend/services/bot_channel_service.py create mode 100644 backend/services/bot_config_state_service.py create mode 100644 backend/services/bot_infra_service.py create mode 100644 backend/services/bot_lifecycle_service.py create mode 100644 backend/services/bot_message_service.py create mode 100644 backend/services/bot_query_service.py create mode 100644 backend/services/bot_runtime_snapshot_service.py create mode 100644 backend/services/dashboard_auth_service.py create mode 100644 backend/services/image_service.py create mode 100644 backend/services/platform_activity_service.py create mode 100644 backend/services/platform_analytics_service.py create mode 100644 backend/services/platform_common.py create mode 100644 backend/services/platform_overview_service.py create mode 100644 backend/services/platform_settings_service.py create mode 100644 backend/services/platform_settings_support.py create mode 100644 backend/services/platform_usage_service.py create mode 100644 backend/services/provider_test_service.py create mode 100644 backend/services/runtime_event_service.py create mode 100644 backend/services/skill_service.py create mode 100644 backend/services/speech_transcription_service.py create mode 100644 backend/services/sys_auth_service.py create mode 100644 backend/services/system_service.py create mode 100644 design/code-structure-standards.md create mode 100644 frontend/src/App.mobile.css create mode 100644 frontend/src/app/AppChrome.tsx create mode 100644 frontend/src/app/AppShellViews.tsx create mode 100644 frontend/src/app/appRouteMeta.ts create mode 100644 frontend/src/components/lucent/LucentDrawer.tsx create mode 100644 frontend/src/modules/chat/ChatWorkspacePage.tsx create mode 100644 frontend/src/modules/dashboard/api/dashboardConfigApi.ts create mode 100644 frontend/src/modules/dashboard/botDashboardShared.tsx create mode 100644 frontend/src/modules/dashboard/botPanels.ts create mode 100644 frontend/src/modules/dashboard/components/BotChannelFieldEditor.tsx create mode 100644 frontend/src/modules/dashboard/components/BotConfigDrawerParts.tsx create mode 100644 frontend/src/modules/dashboard/components/BotDashboardBotListPanel.tsx create mode 100644 frontend/src/modules/dashboard/components/BotDashboardConfigDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/BotDashboardConversationNodes.tsx create mode 100644 frontend/src/modules/dashboard/components/BotDashboardRuntimePanel.tsx create mode 100644 frontend/src/modules/dashboard/components/ChatComposerDock.tsx create mode 100644 frontend/src/modules/dashboard/components/ChatPanelParts.tsx create mode 100644 frontend/src/modules/dashboard/components/RuntimePanelParts.tsx create mode 100644 frontend/src/modules/dashboard/components/WorkspacePanelParts.tsx create mode 100644 frontend/src/modules/dashboard/components/config-drawers/ChannelTopicDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/config-drawers/GeneralDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/config-drawers/SkillsMcpDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/config-drawers/shared.ts create mode 100644 frontend/src/modules/dashboard/components/dashboard-drawers/BotDashboardChannelTopicDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/dashboard-drawers/BotDashboardOperationsDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/dashboard-drawers/BotDashboardPrimaryDrawers.tsx create mode 100644 frontend/src/modules/dashboard/components/dashboard-drawers/types.ts create mode 100644 frontend/src/modules/dashboard/hooks/dashboardChatShared.ts create mode 100644 frontend/src/modules/dashboard/hooks/useBotDashboardConfigState.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChannelConfig.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChat.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatCommandControl.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatComposer.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatHistory.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatMediaInput.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardMcpCronConfig.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardRuntimeControl.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardSkillsEnvConfig.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardTopicConfig.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardWorkspace.ts create mode 100644 frontend/src/modules/dashboard/shared/botDashboardConstants.ts create mode 100644 frontend/src/modules/dashboard/shared/botDashboardConversation.ts create mode 100644 frontend/src/modules/dashboard/shared/botDashboardTopicUtils.ts create mode 100644 frontend/src/modules/dashboard/shared/botDashboardTypes.ts create mode 100644 frontend/src/modules/dashboard/shared/botDashboardUtils.ts create mode 100644 frontend/src/modules/dashboard/shared/botDashboardWorkspace.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/BotWizardChannelModal.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/BotWizardStepFour.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/BotWizardStepOne.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/BotWizardStepThree.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/BotWizardStepTwo.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/BotWizardToolsModal.tsx create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/constants.ts create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/types.ts create mode 100644 frontend/src/modules/onboarding/components/bot-wizard/utils.ts create mode 100644 frontend/src/modules/platform/PlatformHomePage.tsx create mode 100644 frontend/src/modules/platform/components/AdminAccessPlaceholderPage.tsx create mode 100644 frontend/src/modules/platform/components/BotWorkspaceBrowser.tsx create mode 100644 frontend/src/modules/platform/components/PlatformSettingsPage.tsx create mode 100644 frontend/src/modules/platform/components/RoleManagementPage.tsx create mode 100644 frontend/src/modules/platform/components/TemplateManagerPage.tsx create mode 100644 frontend/src/modules/platform/components/UserManagementPage.tsx create mode 100644 frontend/src/modules/platform/components/UserProfilePage.tsx create mode 100644 frontend/src/modules/platform/components/bot-workspace-browser/BotWorkspacePreviewModal.tsx create mode 100644 frontend/src/modules/platform/components/bot-workspace-browser/shared.tsx create mode 100644 frontend/src/modules/platform/components/bot-workspace-browser/useBotWorkspaceBrowser.ts create mode 100644 frontend/src/modules/platform/components/node-home/NodeEditorDrawer.tsx create mode 100644 frontend/src/modules/platform/components/node-home/useNodeHomePage.ts create mode 100644 frontend/src/modules/platform/components/node-workspace/NodeWorkspaceBotDetailPanel.tsx create mode 100644 frontend/src/modules/platform/components/node-workspace/NodeWorkspaceBotListPanel.tsx create mode 100644 frontend/src/modules/platform/components/node-workspace/NodeWorkspaceOverlays.tsx create mode 100644 frontend/src/modules/platform/components/node-workspace/NodeWorkspaceSummaryCards.tsx create mode 100644 frontend/src/modules/platform/components/node-workspace/types.ts create mode 100644 frontend/src/modules/platform/components/node-workspace/useNodeWorkspacePage.ts create mode 100644 frontend/src/modules/platform/components/node-workspace/utils.ts create mode 100644 frontend/src/modules/platform/components/platform-dashboard/PlatformDashboardBotListPanel.tsx create mode 100644 frontend/src/modules/platform/components/platform-dashboard/PlatformDashboardOverlays.tsx create mode 100644 frontend/src/modules/platform/components/platform-dashboard/PlatformDashboardSections.tsx create mode 100644 frontend/src/modules/platform/components/platform-dashboard/types.ts create mode 100644 frontend/src/modules/platform/components/platform-dashboard/usePlatformDashboardPage.ts create mode 100644 frontend/src/modules/platform/components/platform-dashboard/utils.ts create mode 100644 frontend/src/styles/app-common.css create mode 100644 frontend/src/styles/app-platform.css create mode 100644 frontend/src/styles/app-shell.css create mode 100644 frontend/src/styles/app-theme.css create mode 100644 frontend/src/types/sys.ts delete mode 100644 frontend/src/utils/panelAccess.ts create mode 100644 frontend/src/utils/sessionAuth.ts diff --git a/backend/api/dashboard_assets_router.py b/backend/api/dashboard_assets_router.py new file mode 100644 index 0000000..4af1434 --- /dev/null +++ b/backend/api/dashboard_assets_router.py @@ -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 diff --git a/backend/api/dashboard_bot_admin_router.py b/backend/api/dashboard_bot_admin_router.py new file mode 100644 index 0000000..4f132da --- /dev/null +++ b/backend/api/dashboard_bot_admin_router.py @@ -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 diff --git a/backend/api/dashboard_bot_io_router.py b/backend/api/dashboard_bot_io_router.py new file mode 100644 index 0000000..6e44e37 --- /dev/null +++ b/backend/api/dashboard_bot_io_router.py @@ -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 diff --git a/backend/api/dashboard_router.py b/backend/api/dashboard_router.py new file mode 100644 index 0000000..c9f8f7f --- /dev/null +++ b/backend/api/dashboard_router.py @@ -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 diff --git a/backend/api/dashboard_router_support.py b/backend/api/dashboard_router_support.py new file mode 100644 index 0000000..b842cec --- /dev/null +++ b/backend/api/dashboard_router_support.py @@ -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 diff --git a/backend/api/platform_admin_router.py b/backend/api/platform_admin_router.py new file mode 100644 index 0000000..f8fe9d8 --- /dev/null +++ b/backend/api/platform_admin_router.py @@ -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) diff --git a/backend/api/platform_node_catalog_router.py b/backend/api/platform_node_catalog_router.py new file mode 100644 index 0000000..402fb28 --- /dev/null +++ b/backend/api/platform_node_catalog_router.py @@ -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} diff --git a/backend/api/platform_node_probe_router.py b/backend/api/platform_node_probe_router.py new file mode 100644 index 0000000..cee78d0 --- /dev/null +++ b/backend/api/platform_node_probe_router.py @@ -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() diff --git a/backend/api/platform_node_resource_router.py b/backend/api/platform_node_resource_router.py new file mode 100644 index 0000000..0b163e1 --- /dev/null +++ b/backend/api/platform_node_resource_router.py @@ -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) diff --git a/backend/api/platform_node_support.py b/backend/api/platform_node_support.py new file mode 100644 index 0000000..f1b92d7 --- /dev/null +++ b/backend/api/platform_node_support.py @@ -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, + ) diff --git a/backend/api/platform_nodes_router.py b/backend/api/platform_nodes_router.py new file mode 100644 index 0000000..8897157 --- /dev/null +++ b/backend/api/platform_nodes_router.py @@ -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) + diff --git a/backend/api/platform_overview_router.py b/backend/api/platform_overview_router.py new file mode 100644 index 0000000..24f6a61 --- /dev/null +++ b/backend/api/platform_overview_router.py @@ -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) diff --git a/backend/api/platform_router.py b/backend/api/platform_router.py index 7836b8d..88ca3e4 100644 --- a/backend/api/platform_router.py +++ b/backend/api/platform_router.py @@ -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} diff --git a/backend/api/platform_settings_router.py b/backend/api/platform_settings_router.py new file mode 100644 index 0000000..6c3c29e --- /dev/null +++ b/backend/api/platform_settings_router.py @@ -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} diff --git a/backend/api/platform_shared.py b/backend/api/platform_shared.py new file mode 100644 index 0000000..84a9f60 --- /dev/null +++ b/backend/api/platform_shared.py @@ -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() diff --git a/backend/api/sys_router.py b/backend/api/sys_router.py new file mode 100644 index 0000000..d72d265 --- /dev/null +++ b/backend/api/sys_router.py @@ -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} diff --git a/backend/api/system_runtime_router.py b/backend/api/system_runtime_router.py new file mode 100644 index 0000000..2b06e93 --- /dev/null +++ b/backend/api/system_runtime_router.py @@ -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 diff --git a/backend/app_factory.py b/backend/app_factory.py new file mode 100644 index 0000000..85a2b0d --- /dev/null +++ b/backend/app_factory.py @@ -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() diff --git a/backend/bootstrap/app_runtime.py b/backend/bootstrap/app_runtime.py new file mode 100644 index 0000000..e8b9b66 --- /dev/null +++ b/backend/bootstrap/app_runtime.py @@ -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, + ) diff --git a/backend/bootstrap/app_runtime_support.py b/backend/bootstrap/app_runtime_support.py new file mode 100644 index 0000000..c1aecf7 --- /dev/null +++ b/backend/bootstrap/app_runtime_support.py @@ -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, + ) + ) diff --git a/backend/clients/edge/base.py b/backend/clients/edge/base.py index 8ac94c8..a278ef4 100644 --- a/backend/clients/edge/base.py +++ b/backend/clients/edge/base.py @@ -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, diff --git a/backend/clients/edge/http.py b/backend/clients/edge/http.py index 2a5377e..6bc0b5c 100644 --- a/backend/clients/edge/http.py +++ b/backend/clients/edge/http.py @@ -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, *, diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index b2c1610..4a331bc 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -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", diff --git a/backend/core/database.py b/backend/core/database.py index 9ee8fc5..e3cfe53 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -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) diff --git a/backend/core/docker_manager.py b/backend/core/docker_manager.py index 5e1f05b..3652d19 100644 --- a/backend/core/docker_manager.py +++ b/backend/core/docker_manager.py @@ -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, diff --git a/backend/core/settings.py b/backend/core/settings.py index 758bf09..3f92f21 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -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() diff --git a/backend/core/speech_service.py b/backend/core/speech_service.py index 6cee286..b365309 100644 --- a/backend/core/speech_service.py +++ b/backend/core/speech_service.py @@ -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): diff --git a/backend/main.py b/backend/main.py index f9efac6..9f5030d 100644 --- a/backend/main.py +++ b/backend/main.py @@ -1,4813 +1,6 @@ -import asyncio -import json -import logging import os -import re -import shutil -import tempfile -import time -import zipfile -from datetime import datetime, timedelta, timezone -from typing import Any, Dict, List, Optional -from urllib.parse import quote, unquote -from zoneinfo import ZoneInfo -import httpx -from pydantic import BaseModel -from fastapi import Depends, FastAPI, File, Form, HTTPException, Request, UploadFile, WebSocket, WebSocketDisconnect -from fastapi.responses import JSONResponse -from fastapi.middleware.cors import CORSMiddleware -from sqlmodel import Session, select - -from core.config_manager import BotConfigManager -from core.cache import cache -from core.database import engine, get_session, init_database -from core.docker_manager import BotDockerManager -from core.speech_service import ( - SpeechDisabledError, - SpeechDurationError, - SpeechServiceError, - WhisperSpeechService, -) -from core.settings import ( - BOTS_WORKSPACE_ROOT, - DATA_ROOT, - DATABASE_ECHO, - DATABASE_ENGINE, - DATABASE_URL_DISPLAY, - AGENT_MD_TEMPLATES_FILE, - DEFAULT_AGENTS_MD, - DEFAULT_BOT_SYSTEM_TIMEZONE, - DEFAULT_IDENTITY_MD, - DEFAULT_SOUL_MD, - DEFAULT_TOOLS_MD, - DEFAULT_USER_MD, - PANEL_ACCESS_PASSWORD, - PROJECT_ROOT, - REDIS_ENABLED, - REDIS_PREFIX, - REDIS_URL, - TOPIC_PRESET_TEMPLATES, - TOPIC_PRESETS_TEMPLATES_FILE, - load_agent_md_templates, - load_topic_presets_template, -) -from models.bot import BotInstance, BotMessage, NanobotImage -from models.platform import BotActivityEvent, BotRequestUsage -from models.skill import BotSkillInstall, SkillMarketItem -from models.topic import TopicItem, TopicTopic -from api.platform_router import router as platform_router -from api.topic_router import router as topic_router -from clients.edge.errors import is_expected_edge_offline_error, log_edge_failure, summarize_edge_exception -from clients.edge.http import HttpEdgeClient -from services.topic_runtime import publish_runtime_topic_packet -from services.platform_service import ( - bind_usage_message, - create_usage_request, - fail_latest_usage, - finalize_usage_from_packet, - get_chat_pull_page_size, - get_platform_settings_snapshot, - get_speech_runtime_settings, - prune_expired_activity_events, - record_activity_event, -) -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.bot_command_service import BotCommandService -from services.node_registry_service import ManagedNode, NodeRegistryService -from services.runtime_service import RuntimeService -from services.workspace_service import WorkspaceService - -app = FastAPI(title="Dashboard Nanobot API") -logger = logging.getLogger("dashboard.backend") - - -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) - -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_]+$") -BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password" - - -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 - access_password: Optional[str] = None - 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 - access_password: Optional[str] = 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 BotPageAuthLoginRequest(BaseModel): - password: str - - -class CommandRequest(BaseModel): - command: Optional[str] = None - attachments: Optional[List[str]] = None - - -class MessageFeedbackRequest(BaseModel): - feedback: Optional[str] = None # up | down | null - - -class WorkspaceFileUpdateRequest(BaseModel): - content: str - - -class PanelLoginRequest(BaseModel): - password: Optional[str] = None - - -class SystemTemplatesUpdateRequest(BaseModel): - agent_md_templates: Optional[Dict[str, str]] = None - topic_presets: Optional[Dict[str, Any]] = None - - -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(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 = _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(raw: Any, bot_id: str) -> List[str]: - if not isinstance(raw, list): - return [] - rows: List[str] = [] - for v in raw: - s = _normalize_media_item(bot_id, v) - if s: - rows.append(s) - return rows - - -def _persist_runtime_packet(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 = _normalize_packet_channel(packet) - if source_channel != "dashboard": - return None - persisted_message_id: Optional[int] = None - with Session(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 = str(payload.get("action_msg") or payload.get("msg") or "").strip() - if state: - bot.current_state = state - if action: - bot.last_action = action[:4000] - elif packet_type == "ASSISTANT_MESSAGE": - bot.current_state = "IDLE" - text_msg = str(packet.get("text") or "").strip() - media_list = _normalize_media_list(packet.get("media"), bot_id) - if text_msg or media_list: - if text_msg: - bot.last_action = " ".join(text_msg.split())[:4000] - 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 - usage_row = 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 = _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 - bind_usage_message( - session, - bot_id, - str(packet.get("request_id") or "").strip(), - persisted_message_id, - ) - elif packet_type == "BUS_EVENT": - # Dashboard channel emits BUS_EVENT for both progress and final replies. - # Persist only non-progress events to keep durable chat history clean. - 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 = _normalize_media_list(packet.get("media"), bot_id) - if text_msg or media_list: - bot.current_state = "IDLE" - if text_msg: - bot.last_action = " ".join(text_msg.split())[:4000] - 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 - usage_row = 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() - - publish_runtime_topic_packet( - engine, - bot_id, - packet, - source_channel, - persisted_message_id, - logger, - ) - - if persisted_message_id: - packet["message_id"] = persisted_message_id - if packet_type in {"ASSISTANT_MESSAGE", "USER_COMMAND", "BUS_EVENT"}: - _invalidate_bot_messages_cache(bot_id) - _invalidate_bot_detail_cache(bot_id) - return persisted_message_id - - -class WSConnectionManager: - def __init__(self): - 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) - - -manager = WSConnectionManager() - - -def _broadcast_runtime_packet(bot_id: str, packet: Dict[str, Any], loop: Any) -> None: - asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop) - -PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password" - - -def _extract_bot_id_from_api_path(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_panel_password_http(request: Request) -> str: - header_value = str(request.headers.get(PANEL_ACCESS_PASSWORD_HEADER) or "").strip() - if header_value: - return header_value - query_value = str(request.query_params.get("panel_access_password") or "").strip() - return query_value - - -def _get_supplied_bot_access_password_http(request: Request) -> str: - header_value = str(request.headers.get(BOT_ACCESS_PASSWORD_HEADER) or "").strip() - if header_value: - return header_value - query_value = str(request.query_params.get("bot_access_password") or "").strip() - return query_value - - -def _validate_panel_access_password(supplied: str) -> Optional[str]: - configured = str(PANEL_ACCESS_PASSWORD or "").strip() - if not configured: - return None - candidate = str(supplied or "").strip() - if not candidate: - return "Panel access password required" - if candidate != configured: - return "Invalid panel access password" - return None - - -def _validate_bot_access_password(bot: BotInstance, supplied: str) -> Optional[str]: - configured = str(getattr(bot, "access_password", "") or "").strip() - if not configured: - return None - candidate = str(supplied or "").strip() - if not candidate: - return "Bot access password required" - if candidate != configured: - return "Invalid bot access password" - return None - - -def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool: - raw = str(path or "").strip() - verb = str(method or "GET").strip().upper() - if not raw.startswith("/api/"): - return False - if raw in { - "/api/panel/auth/status", - "/api/panel/auth/login", - "/api/health", - "/api/health/cache", - }: - return False - if _is_bot_panel_management_api_path(raw, verb): - return True - # Other bot-scoped APIs are not protected by panel password. - if _extract_bot_id_from_api_path(raw): - return False - return True - - -def _is_bot_panel_management_api_path(path: str, method: str = "GET") -> bool: - raw = str(path or "").strip() - verb = str(method or "GET").strip().upper() - if not raw.startswith("/api/bots/"): - return False - bot_id = _extract_bot_id_from_api_path(raw) - if not bot_id: - return False - return ( - raw.endswith("/start") - or raw.endswith("/stop") - or raw.endswith("/enable") - or raw.endswith("/disable") - or raw.endswith("/deactivate") - or (verb in {"PUT", "DELETE"} and raw == f"/api/bots/{bot_id}") - ) - - -def _is_bot_enable_api_path(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 = _extract_bot_id_from_api_path(raw) - if not bot_id: - return False - return raw == f"/api/bots/{bot_id}/enable" - - -@app.middleware("http") -async def bot_access_password_guard(request: Request, call_next): - if request.method.upper() == "OPTIONS": - return await call_next(request) - - bot_id = _extract_bot_id_from_api_path(request.url.path) - if not bot_id: - if _is_panel_protected_api_path(request.url.path, request.method): - panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) - if panel_error: - return JSONResponse(status_code=401, content={"detail": panel_error}) - return await call_next(request) - - with Session(engine) as session: - bot = session.get(BotInstance, bot_id) - if not bot: - return JSONResponse(status_code=404, content={"detail": "Bot not found"}) - - if _is_bot_panel_management_api_path(request.url.path, request.method): - panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) - if panel_error: - bot_error = _validate_bot_access_password(bot, _get_supplied_bot_access_password_http(request)) - if bot_error: - return JSONResponse(status_code=401, content={"detail": bot_error}) - - enabled = bool(getattr(bot, "enabled", True)) - if not enabled: - is_enable_api = _is_bot_enable_api_path(request.url.path, request.method) - is_read_api = request.method.upper() == "GET" - is_auth_login = request.method.upper() == "POST" and request.url.path == f"/api/bots/{bot_id}/auth/login" - if not (is_enable_api or is_read_api or is_auth_login): - return JSONResponse(status_code=403, content={"detail": "Bot is disabled. Enable it first."}) - return await call_next(request) - - -@app.get("/api/panel/auth/status") -def get_panel_auth_status(): - configured = str(PANEL_ACCESS_PASSWORD or "").strip() - return {"enabled": bool(configured)} - - -@app.post("/api/panel/auth/login") -def panel_login(payload: PanelLoginRequest): - configured = str(PANEL_ACCESS_PASSWORD or "").strip() - if not configured: - return {"success": True, "enabled": False} - supplied = str(payload.password or "").strip() - if supplied != configured: - raise HTTPException(status_code=401, detail="Invalid panel access password") - return {"success": True, "enabled": True} - - -def docker_callback(bot_id: str, packet: Dict[str, Any]): - _persist_runtime_packet(bot_id, packet) - loop = getattr(app.state, "main_loop", None) - if not loop or not loop.is_running(): - return - asyncio.run_coroutine_threadsafe(manager.broadcast(bot_id, packet), loop) - - -def _cache_key_bots_list() -> str: - return "bots:list" - - -def _cache_key_bot_detail(bot_id: str) -> str: - return f"bot:detail:{bot_id}" - - -def _cache_key_bot_messages(bot_id: str, limit: int) -> str: - return f"bot:messages:v2:{bot_id}:limit:{limit}" - - -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}" - - -def _serialize_bot_message_row(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": _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), - } - - -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 _cache_key_images() -> str: - return "images:list" - - -def _invalidate_bot_detail_cache(bot_id: str) -> None: - cache.delete(_cache_key_bots_list(), _cache_key_bot_detail(bot_id)) - - -def _invalidate_bot_messages_cache(bot_id: str) -> None: - cache.delete_prefix(f"bot:messages:{bot_id}:") - - -def _invalidate_images_cache() -> None: - cache.delete(_cache_key_images()) - - -@app.on_event("startup") -async def on_startup(): - app.state.main_loop = asyncio.get_running_loop() - _provider_target_overrides.clear() - logger.info( - "startup project_root=%s db_engine=%s db_echo=%s db_url=%s redis=%s panel_password=%s", - PROJECT_ROOT, - DATABASE_ENGINE, - DATABASE_ECHO, - DATABASE_URL_DISPLAY, - "enabled" if cache.ping() else ("disabled" if REDIS_ENABLED else "not_configured"), - "enabled" if str(PANEL_ACCESS_PASSWORD or "").strip() else "disabled", - ) - init_database() - cache.delete_prefix("") - with Session(engine) as session: - node_registry_service.load_from_session(session) - node_registry_service.upsert_node(session, _local_managed_node()) - pruned_events = prune_expired_activity_events(session, force=True) - if pruned_events > 0: - session.commit() - target_dirty = False - for bot in session.exec(select(BotInstance)).all(): - _migrate_bot_resources_store(bot.id) - target = _resolve_bot_provider_target_for_instance(bot) - if str(target.transport_kind or "").strip().lower() != "edge": - target = normalize_provider_target( - { - "node_id": target.node_id, - "transport_kind": "edge", - "runtime_kind": target.runtime_kind, - "core_adapter": target.core_adapter, - }, - fallback=_default_provider_target(), - ) - _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 - ): - _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: - runtime_service.ensure_monitor(app_state=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(): - runtime_service.sync_edge_monitor_packets( - app_state=app.state, - bot=bot, - request_id=str(pending_usage.request_id or "").strip(), - ) - except HTTPException as exc: - 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: - logger.exception("Failed to restore runtime monitor on startup for bot_id=%s", str(bot.id or "")) - - -def _provider_defaults(provider: str) -> tuple[str, str]: - p = provider.lower().strip() - if p in {"openrouter"}: - return "openrouter", "https://openrouter.ai/api/v1" - if p in {"dashscope", "aliyun", "qwen", "aliyun-qwen"}: - return "dashscope", "https://dashscope.aliyuncs.com/compatible-mode/v1" - if p in {"xunfei", "iflytek", "xfyun"}: - return "openai", "https://spark-api-open.xf-yun.com/v1" - if p in {"kimi", "moonshot"}: - return "kimi", "https://api.moonshot.cn/v1" - if p in {"minimax"}: - return "minimax", "https://api.minimax.chat/v1" - if p in {"vllm"}: - return "openai", "" - return p, "" - - -@app.get("/api/system/defaults") -def get_system_defaults(): - md_templates = load_agent_md_templates() - topic_presets = load_topic_presets_template() - platform_settings = get_platform_settings_snapshot() - speech_settings = get_speech_runtime_settings() - return { - "templates": { - "soul_md": md_templates.get("soul_md") or DEFAULT_SOUL_MD, - "agents_md": md_templates.get("agents_md") or DEFAULT_AGENTS_MD, - "user_md": md_templates.get("user_md") or DEFAULT_USER_MD, - "tools_md": md_templates.get("tools_md") or DEFAULT_TOOLS_MD, - "identity_md": md_templates.get("identity_md") or 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": _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 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 _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 f: - json.dump(payload, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -def _write_text_atomic(path: str, content: str) -> None: - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8", newline="") as f: - f.write(str(content or "")) - os.replace(tmp, path) - - -@app.get("/api/system/templates") -def get_system_templates(): - return { - "agent_md_templates": load_agent_md_templates(), - "topic_presets": load_topic_presets_template(), - } - - -@app.put("/api/system/templates") -def update_system_templates(payload: SystemTemplatesUpdateRequest): - 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") - _write_json_atomic(str(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") - _write_json_atomic(str(TOPIC_PRESETS_TEMPLATES_FILE), normalized_topic) - - return { - "status": "ok", - "agent_md_templates": load_agent_md_templates(), - "topic_presets": load_topic_presets_template(), - } - - -@app.get("/api/health") -def get_health(): - try: - with Session(engine) as session: - session.exec(select(BotInstance).limit(1)).first() - return {"status": "ok", "database": DATABASE_ENGINE} - except Exception as e: - raise HTTPException(status_code=503, detail=f"database check failed: {e}") - - -@app.get("/api/health/cache") -def get_cache_health(): - redis_url = str(REDIS_URL or "").strip() - configured = bool(REDIS_ENABLED and redis_url) - client_enabled = bool(getattr(cache, "enabled", False)) - reachable = bool(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": REDIS_PREFIX, - }, - } - - -def _config_json_path(bot_id: str) -> str: - return os.path.join(_bot_data_root(bot_id), "config.json") - - -def _read_bot_config(bot_id: str) -> Dict[str, Any]: - if _resolve_edge_state_context(bot_id) is not None: - data = _read_edge_state_data(bot_id=bot_id, state_key="config", default_payload={}) - return data if isinstance(data, dict) else {} - path = _config_json_path(bot_id) - if not os.path.isfile(path): - return {} - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None: - normalized = dict(config_data if isinstance(config_data, dict) else {}) - if _write_edge_state_data(bot_id=bot_id, state_key="config", data=normalized): - return - path = _config_json_path(bot_id) - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(normalized, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -_provider_target_overrides: Dict[str, ProviderTarget] = {} - - -def _default_provider_target() -> ProviderTarget: - return normalize_provider_target( - { - "node_id": getattr(app.state, "provider_default_node_id", None), - "transport_kind": getattr(app.state, "provider_default_transport_kind", None), - "runtime_kind": getattr(app.state, "provider_default_runtime_kind", None), - "core_adapter": getattr(app.state, "provider_default_core_adapter", None), - }, - fallback=ProviderTarget(), - ) - - -def _read_bot_provider_target(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> ProviderTarget: - normalized_bot_id = str(bot_id or "").strip() - if normalized_bot_id and normalized_bot_id in _provider_target_overrides: - return _provider_target_overrides[normalized_bot_id] - if normalized_bot_id: - with Session(engine) as session: - bot = session.get(BotInstance, normalized_bot_id) - if bot is not None: - return normalize_provider_target( - { - "node_id": getattr(bot, "node_id", None), - "transport_kind": getattr(bot, "transport_kind", None), - "runtime_kind": getattr(bot, "runtime_kind", None), - "core_adapter": getattr(bot, "core_adapter", None), - }, - fallback=_default_provider_target(), - ) - raw_config = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id) - return provider_target_from_config(raw_config, fallback=_default_provider_target()) - - -def _resolve_bot_provider_target_for_instance(bot: BotInstance) -> ProviderTarget: - normalized_bot_id = str(getattr(bot, "id", "") or "").strip() - if normalized_bot_id and normalized_bot_id in _provider_target_overrides: - return _provider_target_overrides[normalized_bot_id] - inline_values = { - "node_id": getattr(bot, "node_id", None), - "transport_kind": getattr(bot, "transport_kind", None), - "runtime_kind": getattr(bot, "runtime_kind", None), - "core_adapter": getattr(bot, "core_adapter", None), - } - if any(str(value or "").strip() for value in inline_values.values()): - return normalize_provider_target(inline_values, fallback=_default_provider_target()) - return _read_bot_provider_target(str(bot.id or "")) - - -def _set_provider_target_override(bot_id: str, target: ProviderTarget) -> None: - normalized_bot_id = str(bot_id or "").strip() - if not normalized_bot_id: - return - _provider_target_overrides[normalized_bot_id] = target - - -def _clear_provider_target_override(bot_id: str) -> None: - normalized_bot_id = str(bot_id or "").strip() - if not normalized_bot_id: - return - _provider_target_overrides.pop(normalized_bot_id, None) - - -def _apply_provider_target_to_bot(bot: BotInstance, target: ProviderTarget) -> None: - bot.node_id = target.node_id - bot.transport_kind = target.transport_kind - bot.runtime_kind = target.runtime_kind - bot.core_adapter = target.core_adapter - - -def _local_managed_node() -> ManagedNode: - return ManagedNode( - node_id="local", - display_name="Local Node", - base_url=str(os.getenv("LOCAL_EDGE_BASE_URL", "http://127.0.0.1:8010") or "http://127.0.0.1:8010").strip(), - enabled=True, - auth_token=str(os.getenv("EDGE_AUTH_TOKEN", "") or "").strip(), - metadata={ - "transport_kind": "edge", - "runtime_kind": "docker", - "core_adapter": "nanobot", - "workspace_root": str( - os.getenv("EDGE_WORKSPACE_ROOT", os.getenv("EDGE_BOTS_WORKSPACE_ROOT", "")) or "" - ).strip(), - "native_command": str(os.getenv("EDGE_NATIVE_COMMAND", "") or "").strip(), - "native_workdir": str(os.getenv("EDGE_NATIVE_WORKDIR", "") or "").strip(), - "native_sandbox_mode": str(os.getenv("EDGE_NATIVE_SANDBOX_MODE", "inherit") or "inherit").strip().lower(), - }, - ) - - -def _provider_target_from_node(node_id: Optional[str]) -> Optional[ProviderTarget]: - normalized = str(node_id or "").strip().lower() - if not normalized: - return None - node = node_registry_service.get_node(normalized) - if node is None: - return None - metadata = dict(node.metadata or {}) - return ProviderTarget( - node_id=node.node_id, - transport_kind=str(metadata.get("transport_kind") or "edge").strip().lower() or "edge", - runtime_kind=str(metadata.get("runtime_kind") or "docker").strip().lower() or "docker", - core_adapter=str(metadata.get("core_adapter") or "nanobot").strip().lower() or "nanobot", - ) - - -node_registry_service = NodeRegistryService() -node_registry_service.register_node(_local_managed_node()) -app.state.node_registry_service = node_registry_service - - -def _node_display_name(node_id: str) -> str: - node = node_registry_service.get_node(node_id) - if node is not None: - return str(node.display_name or node.node_id or node_id).strip() or str(node_id or "").strip() - return str(node_id or "").strip() - - -def _node_metadata(node_id: str) -> Dict[str, Any]: - node = node_registry_service.get_node(node_id) - if node is None: - return {} - return dict(node.metadata or {}) - - -def _serialize_provider_target_summary(target: ProviderTarget) -> Dict[str, Any]: - return { - **provider_target_to_dict(target), - "node_display_name": _node_display_name(target.node_id), - } - - -def _resolve_edge_client(target: ProviderTarget) -> HttpEdgeClient: - try: - node = node_registry_service.require_node(target.node_id) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - return HttpEdgeClient( - node=node, - http_client_factory=lambda: httpx.Client(timeout=15.0, trust_env=False), - async_http_client_factory=lambda: httpx.AsyncClient(timeout=15.0, trust_env=False), - ) - - -def _resolve_edge_state_context(bot_id: str) -> Optional[tuple[HttpEdgeClient, Optional[str], str]]: - normalized_bot_id = str(bot_id or "").strip() - if not normalized_bot_id: - return None - with Session(engine) as session: - bot = session.get(BotInstance, normalized_bot_id) - if bot is None: - return None - target = _resolve_bot_provider_target_for_instance(bot) - if str(target.transport_kind or "").strip().lower() != "edge": - return None - client = _resolve_edge_client(target) - metadata = _node_metadata(target.node_id) - workspace_root = str(metadata.get("workspace_root") or "").strip() or None - return client, workspace_root, target.node_id - - -def _read_edge_state_data( - *, - bot_id: str, - state_key: str, - default_payload: Dict[str, Any], -) -> Dict[str, Any]: - context = _resolve_edge_state_context(bot_id) - if context is None: - return dict(default_payload) - client, workspace_root, node_id = context - try: - payload = client.read_state( - bot_id=bot_id, - state_key=state_key, - workspace_root=workspace_root, - ) - except Exception as exc: - log_edge_failure( - logger, - key=f"edge-state-read:{node_id}:{bot_id}:{state_key}", - exc=exc, - message=f"Failed to read edge state for bot_id={bot_id}, state_key={state_key}", - ) - return dict(default_payload) - data = payload.get("data") - if isinstance(data, dict): - return dict(data) - return dict(default_payload) - - -def _write_edge_state_data( - *, - bot_id: str, - state_key: str, - data: Dict[str, Any], -) -> bool: - context = _resolve_edge_state_context(bot_id) - if context is None: - return False - client, workspace_root, node_id = context - try: - client.write_state( - bot_id=bot_id, - state_key=state_key, - data=dict(data or {}), - workspace_root=workspace_root, - ) - except Exception as exc: - log_edge_failure( - logger, - key=f"edge-state-write:{node_id}:{bot_id}:{state_key}", - exc=exc, - message=f"Failed to write edge state for bot_id={bot_id}, state_key={state_key}", - ) - raise - return True - - -def _resources_json_path(bot_id: str) -> str: - return os.path.join(_bot_data_root(bot_id), "resources.json") - - -def _write_bot_resources(bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> None: - normalized = _normalize_resource_limits(cpu_cores, memory_mb, storage_gb) - payload = { - "cpuCores": normalized["cpu_cores"], - "memoryMB": normalized["memory_mb"], - "storageGB": normalized["storage_gb"], - } - if _write_edge_state_data(bot_id=bot_id, state_key="resources", data=payload): - return - path = _resources_json_path(bot_id) - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(payload, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -def _read_legacy_resource_values(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> tuple[Any, Any, Any]: - cpu_raw: Any = None - memory_raw: Any = None - storage_raw: Any = None - - path = _resources_json_path(bot_id) - if os.path.isfile(path): - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - if isinstance(data, dict): - cpu_raw = data.get("cpuCores", data.get("cpu_cores")) - memory_raw = data.get("memoryMB", data.get("memory_mb")) - storage_raw = data.get("storageGB", data.get("storage_gb")) - except Exception: - pass - - # Backward compatibility: read old runtime.resources only if new file is missing/incomplete. - if cpu_raw is None or memory_raw is None or storage_raw is None: - cfg = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id) - runtime_cfg = cfg.get("runtime") - if isinstance(runtime_cfg, dict): - resources_raw = runtime_cfg.get("resources") - if isinstance(resources_raw, dict): - if cpu_raw is None: - cpu_raw = resources_raw.get("cpuCores", resources_raw.get("cpu_cores")) - if memory_raw is None: - memory_raw = resources_raw.get("memoryMB", resources_raw.get("memory_mb")) - if storage_raw is None: - storage_raw = resources_raw.get("storageGB", resources_raw.get("storage_gb")) - return cpu_raw, memory_raw, storage_raw - - -def _read_bot_resources(bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: - edge_context = _resolve_edge_state_context(bot_id) - cpu_raw: Any = None - memory_raw: Any = None - storage_raw: Any = None - if edge_context is not None: - data = _read_edge_state_data( - bot_id=bot_id, - state_key="resources", - default_payload={}, - ) - cpu_raw = data.get("cpuCores", data.get("cpu_cores")) - memory_raw = data.get("memoryMB", data.get("memory_mb")) - storage_raw = data.get("storageGB", data.get("storage_gb")) - if cpu_raw is None or memory_raw is None or storage_raw is None: - legacy_cpu, legacy_memory, legacy_storage = _read_legacy_resource_values(bot_id, config_data=config_data) - if cpu_raw is None: - cpu_raw = legacy_cpu - if memory_raw is None: - memory_raw = legacy_memory - if storage_raw is None: - storage_raw = legacy_storage - return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw) - - cpu_raw, memory_raw, storage_raw = _read_legacy_resource_values(bot_id, config_data=config_data) - return _normalize_resource_limits(cpu_raw, memory_raw, storage_raw) - - -def _migrate_bot_resources_store(bot_id: str) -> None: - edge_context = _resolve_edge_state_context(bot_id) - if edge_context is not None: - return - - config_data = _read_bot_config(bot_id) - runtime_cfg = config_data.get("runtime") - resources_raw: Dict[str, Any] = {} - if isinstance(runtime_cfg, dict): - legacy_raw = runtime_cfg.get("resources") - if isinstance(legacy_raw, dict): - resources_raw = legacy_raw - - path = _resources_json_path(bot_id) - if not os.path.isfile(path): - _write_bot_resources( - bot_id, - resources_raw.get("cpuCores", resources_raw.get("cpu_cores")), - resources_raw.get("memoryMB", resources_raw.get("memory_mb")), - resources_raw.get("storageGB", resources_raw.get("storage_gb")), - ) - - if isinstance(runtime_cfg, dict) and "resources" in runtime_cfg: - runtime_cfg.pop("resources", None) - if not runtime_cfg: - config_data.pop("runtime", None) - _write_bot_config(bot_id, config_data) - - -def _normalize_channel_extra(raw: Any) -> Dict[str, Any]: - if not isinstance(raw, dict): - return {} - return raw - - -def _normalize_allow_from(raw: Any) -> List[str]: - rows: List[str] = [] - if isinstance(raw, list): - for item in raw: - text = str(item or "").strip() - if text and text not in rows: - rows.append(text) - if not rows: - return ["*"] - return rows - - -def _read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]: - if not isinstance(channels_cfg, dict): - return False, False - send_progress = channels_cfg.get("sendProgress") - send_tool_hints = channels_cfg.get("sendToolHints") - dashboard_cfg = channels_cfg.get("dashboard") - if isinstance(dashboard_cfg, dict): - if send_progress is None and "sendProgress" in dashboard_cfg: - send_progress = dashboard_cfg.get("sendProgress") - if send_tool_hints is None and "sendToolHints" in dashboard_cfg: - send_tool_hints = dashboard_cfg.get("sendToolHints") - return bool(send_progress), bool(send_tool_hints) - - -def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]: - ctype = str(ctype or "").strip().lower() - enabled = bool(cfg.get("enabled", True)) - port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535)) - extra: Dict[str, Any] = {} - external_app_id = "" - app_secret = "" - - if ctype == "feishu": - external_app_id = str(cfg.get("appId") or "") - app_secret = str(cfg.get("appSecret") or "") - extra = { - "encryptKey": cfg.get("encryptKey", ""), - "verificationToken": cfg.get("verificationToken", ""), - "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), - } - elif ctype == "dingtalk": - external_app_id = str(cfg.get("clientId") or "") - app_secret = str(cfg.get("clientSecret") or "") - extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} - elif ctype == "telegram": - app_secret = str(cfg.get("token") or "") - extra = { - "proxy": cfg.get("proxy", ""), - "replyToMessage": bool(cfg.get("replyToMessage", False)), - "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), - } - elif ctype == "slack": - external_app_id = str(cfg.get("botToken") or "") - app_secret = str(cfg.get("appToken") or "") - extra = { - "mode": cfg.get("mode", "socket"), - "replyInThread": bool(cfg.get("replyInThread", True)), - "groupPolicy": cfg.get("groupPolicy", "mention"), - "groupAllowFrom": cfg.get("groupAllowFrom", []), - "reactEmoji": cfg.get("reactEmoji", "eyes"), - } - elif ctype == "qq": - external_app_id = str(cfg.get("appId") or "") - app_secret = str(cfg.get("secret") or "") - extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} - elif ctype == "email": - extra = { - "consentGranted": bool(cfg.get("consentGranted", False)), - "imapHost": str(cfg.get("imapHost") or ""), - "imapPort": int(cfg.get("imapPort") or 993), - "imapUsername": str(cfg.get("imapUsername") or ""), - "imapPassword": str(cfg.get("imapPassword") or ""), - "imapMailbox": str(cfg.get("imapMailbox") or "INBOX"), - "imapUseSsl": bool(cfg.get("imapUseSsl", True)), - "smtpHost": str(cfg.get("smtpHost") or ""), - "smtpPort": int(cfg.get("smtpPort") or 587), - "smtpUsername": str(cfg.get("smtpUsername") or ""), - "smtpPassword": str(cfg.get("smtpPassword") or ""), - "smtpUseTls": bool(cfg.get("smtpUseTls", True)), - "smtpUseSsl": bool(cfg.get("smtpUseSsl", False)), - "fromAddress": str(cfg.get("fromAddress") or ""), - "autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)), - "pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30), - "markSeen": bool(cfg.get("markSeen", True)), - "maxBodyChars": int(cfg.get("maxBodyChars") or 12000), - "subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "), - "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), - } - else: - external_app_id = str( - cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "" - ) - app_secret = str( - cfg.get("appSecret") or cfg.get("clientSecret") or cfg.get("secret") or cfg.get("token") or cfg.get("appToken") or "" - ) - extra = {k: v for k, v in cfg.items() if k not in {"enabled", "port", "appId", "clientId", "botToken", "externalAppId", "appSecret", "clientSecret", "secret", "token", "appToken"}} - - return { - "id": ctype, - "bot_id": bot_id, - "channel_type": ctype, - "external_app_id": external_app_id, - "app_secret": app_secret, - "internal_port": port, - "is_active": enabled, - "extra_config": extra, - "locked": ctype == "dashboard", - } - - -def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]: - ctype = str(row.get("channel_type") or "").strip().lower() - enabled = bool(row.get("is_active", True)) - extra = _normalize_channel_extra(row.get("extra_config")) - external_app_id = str(row.get("external_app_id") or "") - app_secret = str(row.get("app_secret") or "") - port = max(1, min(int(row.get("internal_port") or 8080), 65535)) - - if ctype == "feishu": - return { - "enabled": enabled, - "appId": external_app_id, - "appSecret": app_secret, - "encryptKey": extra.get("encryptKey", ""), - "verificationToken": extra.get("verificationToken", ""), - "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), - } - if ctype == "dingtalk": - return { - "enabled": enabled, - "clientId": external_app_id, - "clientSecret": app_secret, - "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), - } - if ctype == "telegram": - return { - "enabled": enabled, - "token": app_secret, - "proxy": extra.get("proxy", ""), - "replyToMessage": bool(extra.get("replyToMessage", False)), - "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), - } - if ctype == "slack": - return { - "enabled": enabled, - "mode": extra.get("mode", "socket"), - "botToken": external_app_id, - "appToken": app_secret, - "replyInThread": bool(extra.get("replyInThread", True)), - "groupPolicy": extra.get("groupPolicy", "mention"), - "groupAllowFrom": extra.get("groupAllowFrom", []), - "reactEmoji": extra.get("reactEmoji", "eyes"), - } - if ctype == "qq": - return { - "enabled": enabled, - "appId": external_app_id, - "secret": app_secret, - "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), - } - if ctype == "email": - return { - "enabled": enabled, - "consentGranted": bool(extra.get("consentGranted", False)), - "imapHost": str(extra.get("imapHost") or ""), - "imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)), - "imapUsername": str(extra.get("imapUsername") or ""), - "imapPassword": str(extra.get("imapPassword") or ""), - "imapMailbox": str(extra.get("imapMailbox") or "INBOX"), - "imapUseSsl": bool(extra.get("imapUseSsl", True)), - "smtpHost": str(extra.get("smtpHost") or ""), - "smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)), - "smtpUsername": str(extra.get("smtpUsername") or ""), - "smtpPassword": str(extra.get("smtpPassword") or ""), - "smtpUseTls": bool(extra.get("smtpUseTls", True)), - "smtpUseSsl": bool(extra.get("smtpUseSsl", False)), - "fromAddress": str(extra.get("fromAddress") or ""), - "autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)), - "pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)), - "markSeen": bool(extra.get("markSeen", True)), - "maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)), - "subjectPrefix": str(extra.get("subjectPrefix") or "Re: "), - "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), - } - merged = dict(extra) - merged.update( - { - "enabled": enabled, - "appId": external_app_id, - "appSecret": app_secret, - "port": port, - } - ) - return merged - - -def _get_bot_channels_from_config(bot: BotInstance) -> List[Dict[str, Any]]: - config_data = _read_bot_config(bot.id) - channels_cfg = config_data.get("channels") - if not isinstance(channels_cfg, dict): - channels_cfg = {} - - send_progress, send_tool_hints = _read_global_delivery_flags(channels_cfg) - rows: List[Dict[str, Any]] = [ - { - "id": "dashboard", - "bot_id": bot.id, - "channel_type": "dashboard", - "external_app_id": f"dashboard-{bot.id}", - "app_secret": "", - "internal_port": 9000, - "is_active": True, - "extra_config": { - "sendProgress": send_progress, - "sendToolHints": send_tool_hints, - }, - "locked": True, - } - ] - - for ctype, cfg in channels_cfg.items(): - if ctype in {"sendProgress", "sendToolHints", "dashboard"}: - continue - if not isinstance(cfg, dict): - continue - rows.append(_channel_cfg_to_api_dict(bot.id, ctype, cfg)) - return rows - - -def _normalize_initial_channels(bot_id: str, channels: Optional[List[ChannelConfigRequest]]) -> List[Dict[str, Any]]: - rows: List[Dict[str, Any]] = [] - seen_types: set[str] = set() - for c in channels or []: - ctype = (c.channel_type or "").strip().lower() - if not ctype or ctype == "dashboard" or ctype in seen_types: - continue - seen_types.add(ctype) - rows.append( - { - "id": ctype, - "bot_id": bot_id, - "channel_type": ctype, - "external_app_id": (c.external_app_id or "").strip() or f"{ctype}-{bot_id}", - "app_secret": (c.app_secret or "").strip(), - "internal_port": max(1, min(int(c.internal_port or 8080), 65535)), - "is_active": bool(c.is_active), - "extra_config": _normalize_channel_extra(c.extra_config), - "locked": False, - } - ) - return rows - - -def _parse_message_media(bot_id: str, media_raw: Optional[str]) -> List[str]: - if not media_raw: - return [] - try: - parsed = json.loads(media_raw) - return _normalize_media_list(parsed, bot_id) - except Exception: - return [] - - -_ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") - - -def _normalize_env_params(raw: Any) -> Dict[str, str]: - if not isinstance(raw, dict): - return {} - rows: Dict[str, str] = {} - for k, v in raw.items(): - key = str(k or "").strip().upper() - if not key or not _ENV_KEY_RE.fullmatch(key): - continue - rows[key] = str(v or "").strip() - return rows - - -def _get_default_system_timezone() -> str: - value = str(DEFAULT_BOT_SYSTEM_TIMEZONE or "").strip() or "Asia/Shanghai" - try: - ZoneInfo(value) - return value - except Exception: - return "Asia/Shanghai" - - -def _normalize_system_timezone(raw: Any) -> str: - value = str(raw or "").strip() - if not value: - return _get_default_system_timezone() - try: - ZoneInfo(value) - except Exception as exc: - raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc - return value - - -def _resolve_bot_env_params(bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]: - env_params = _normalize_env_params(raw if isinstance(raw, dict) else _read_env_store(bot_id)) - try: - env_params["TZ"] = _normalize_system_timezone(env_params.get("TZ")) - except ValueError: - env_params["TZ"] = _get_default_system_timezone() - return env_params - - -_MCP_SERVER_NAME_RE = re.compile(r"^[A-Za-z0-9._-]{1,64}$") - - -def _normalize_mcp_servers(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 _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 k, v in headers_raw.items(): - hk = str(k or "").strip() - if not hk: - continue - headers[hk] = str(v 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( - current_raw: Any, - normalized: Dict[str, Dict[str, Any]], -) -> Dict[str, Dict[str, Any]]: - """Preserve unknown per-server fields already present in config.json. - - Dashboard only edits a subset of MCP fields (type/url/headers/toolTimeout). - Some MCP providers may rely on additional keys; dropping them can break startup. - """ - 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(config_data: Dict[str, Any]) -> Dict[str, Dict[str, Any]]: - """Normalize tools.mcpServers and drop hidden invalid entries safely. - - Returns the sanitized mcpServers map written into config_data["tools"]["mcpServers"]. - """ - 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 = _normalize_mcp_servers(current_raw) - merged = _merge_mcp_servers_preserving_extras(current_raw, normalized) - tools_cfg["mcpServers"] = merged - config_data["tools"] = tools_cfg - return merged - - -def _parse_env_params(raw: Any) -> Dict[str, str]: - return _normalize_env_params(raw) - - -def _safe_float(raw: Any, default: float) -> float: - try: - return float(raw) - except Exception: - return default - - -def _safe_int(raw: Any, default: int) -> int: - try: - return int(raw) - except Exception: - return default - - -def _normalize_resource_limits(cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> Dict[str, Any]: - cpu = _safe_float(cpu_cores, 1.0) - mem = _safe_int(memory_mb, 1024) - storage = _safe_int(storage_gb, 10) - if cpu < 0: - cpu = 1.0 - if mem < 0: - mem = 1024 - if storage < 0: - storage = 10 - normalized_cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) - normalized_mem = 0 if mem == 0 else min(65536, max(256, mem)) - normalized_storage = 0 if storage == 0 else min(1024, max(1, storage)) - return { - "cpu_cores": normalized_cpu, - "memory_mb": normalized_mem, - "storage_gb": normalized_storage, - } - - -def _read_workspace_md(bot_id: str, filename: str, default_value: str) -> str: - path = os.path.join(_workspace_root(bot_id), filename) - if not os.path.isfile(path): - return default_value - try: - with open(path, "r", encoding="utf-8") as f: - return f.read().strip() - except Exception: - return default_value - - -def _read_bot_runtime_snapshot(bot: BotInstance) -> Dict[str, Any]: - config_data = _read_bot_config(bot.id) - env_params = _resolve_bot_env_params(bot.id) - target = _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 = _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 = _read_workspace_md(bot.id, "SOUL.md", DEFAULT_SOUL_MD) - resources = _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": _safe_float(agents_defaults.get("temperature"), 0.2), - "top_p": _safe_float(agents_defaults.get("topP"), 1.0), - "max_tokens": _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 _get_default_system_timezone(), - "send_progress": send_progress, - "send_tool_hints": send_tool_hints, - "soul_md": soul_md, - "agents_md": _read_workspace_md(bot.id, "AGENTS.md", DEFAULT_AGENTS_MD), - "user_md": _read_workspace_md(bot.id, "USER.md", DEFAULT_USER_MD), - "tools_md": _read_workspace_md(bot.id, "TOOLS.md", DEFAULT_TOOLS_MD), - "identity_md": _read_workspace_md(bot.id, "IDENTITY.md", DEFAULT_IDENTITY_MD), - "system_prompt": soul_md, - } - - -def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: - runtime = _read_bot_runtime_snapshot(bot) - target = _resolve_bot_provider_target_for_instance(bot) - return { - "id": bot.id, - "name": bot.name, - "enabled": bool(getattr(bot, "enabled", True)), - "access_password": bot.access_password or "", - "has_access_password": bool(str(bot.access_password or "").strip()), - "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": _safe_float(runtime.get("temperature"), 0.2), - "top_p": _safe_float(runtime.get("top_p"), 1.0), - "max_tokens": _safe_int(runtime.get("max_tokens"), 8192), - "cpu_cores": _safe_float(runtime.get("cpu_cores"), 1.0), - "memory_mb": _safe_int(runtime.get("memory_mb"), 1024), - "storage_gb": _safe_int(runtime.get("storage_gb"), 10), - "system_timezone": str(runtime.get("system_timezone") or _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": _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(bot: BotInstance) -> Dict[str, Any]: - target = _resolve_bot_provider_target_for_instance(bot) - return { - "id": bot.id, - "name": bot.name, - "enabled": bool(getattr(bot, "enabled", True)), - "has_access_password": bool(str(bot.access_password or "").strip()), - "image_tag": bot.image_tag, - "node_id": target.node_id, - "node_display_name": _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(app_state: Any, bot: BotInstance) -> str: - current_status = str(bot.docker_status or "STOPPED").upper() - try: - status = str(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( - 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 - - -_AGENT_LOOP_READY_MARKER = "Agent loop started" - - -async def _wait_for_agent_loop_ready( - bot_id: str, - timeout_seconds: float = 12.0, - poll_interval_seconds: float = 0.5, -) -> bool: - deadline = time.monotonic() + max(1.0, timeout_seconds) - marker = _AGENT_LOOP_READY_MARKER.lower() - while time.monotonic() < deadline: - logs = 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( - bot_id: str, - timeout_seconds: float = 12.0, - poll_interval_seconds: float = 0.5, -) -> None: - try: - agent_loop_ready = await _wait_for_agent_loop_ready( - bot_id, - timeout_seconds=timeout_seconds, - poll_interval_seconds=poll_interval_seconds, - ) - if agent_loop_ready: - return - if 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." - ) - logger.warning("bot_id=%s agent loop ready marker not found within %ss", bot_id, timeout_seconds) - with Session(engine) as background_session: - if not background_session.get(BotInstance, bot_id): - return - record_activity_event( - background_session, - bot_id, - "bot_warning", - channel="system", - detail=detail, - metadata={ - "kind": "agent_loop_ready_timeout", - "marker": _AGENT_LOOP_READY_MARKER, - "timeout_seconds": timeout_seconds, - }, - ) - background_session.commit() - _invalidate_bot_detail_cache(bot_id) - except Exception: - logger.exception("Failed to record agent loop readiness warning for bot_id=%s", bot_id) - -def _sync_workspace_channels( - session: Session, - bot_id: str, - channels_override: Optional[List[Dict[str, Any]]] = None, - global_delivery_override: Optional[Dict[str, Any]] = None, - runtime_overrides: Optional[Dict[str, Any]] = None, -) -> None: - bot = session.get(BotInstance, bot_id) - if not bot: - return - snapshot = _read_bot_runtime_snapshot(bot) - bot_data: Dict[str, Any] = { - "name": bot.name, - "node_id": snapshot.get("node_id") or _default_provider_target().node_id, - "transport_kind": snapshot.get("transport_kind") or _default_provider_target().transport_kind, - "runtime_kind": snapshot.get("runtime_kind") or _default_provider_target().runtime_kind, - "core_adapter": snapshot.get("core_adapter") or _default_provider_target().core_adapter, - "system_prompt": snapshot.get("system_prompt") or DEFAULT_SOUL_MD, - "soul_md": snapshot.get("soul_md") or DEFAULT_SOUL_MD, - "agents_md": snapshot.get("agents_md") or DEFAULT_AGENTS_MD, - "user_md": snapshot.get("user_md") or DEFAULT_USER_MD, - "tools_md": snapshot.get("tools_md") or DEFAULT_TOOLS_MD, - "identity_md": snapshot.get("identity_md") or DEFAULT_IDENTITY_MD, - "llm_provider": snapshot.get("llm_provider") or "dashscope", - "llm_model": snapshot.get("llm_model") or "", - "api_key": snapshot.get("api_key") or "", - "api_base": snapshot.get("api_base") or "", - "temperature": _safe_float(snapshot.get("temperature"), 0.2), - "top_p": _safe_float(snapshot.get("top_p"), 1.0), - "max_tokens": _safe_int(snapshot.get("max_tokens"), 8192), - "cpu_cores": _safe_float(snapshot.get("cpu_cores"), 1.0), - "memory_mb": _safe_int(snapshot.get("memory_mb"), 1024), - "storage_gb": _safe_int(snapshot.get("storage_gb"), 10), - "send_progress": bool(snapshot.get("send_progress")), - "send_tool_hints": bool(snapshot.get("send_tool_hints")), - } - if isinstance(runtime_overrides, dict): - for key, value in runtime_overrides.items(): - # Keep existing runtime secrets/config when caller sends empty placeholder values. - if key in {"api_key", "llm_provider", "llm_model"}: - text = str(value or "").strip() - if not text: - continue - bot_data[key] = text - continue - if key == "api_base": - # api_base may be intentionally empty (use provider default), so keep explicit value. - bot_data[key] = str(value or "").strip() - continue - bot_data[key] = value - resources = _normalize_resource_limits( - bot_data.get("cpu_cores"), - bot_data.get("memory_mb"), - bot_data.get("storage_gb"), - ) - bot_data["cpu_cores"] = resources["cpu_cores"] - bot_data["memory_mb"] = resources["memory_mb"] - bot_data["storage_gb"] = resources["storage_gb"] - send_progress = bool(bot_data.get("send_progress", False)) - send_tool_hints = bool(bot_data.get("send_tool_hints", False)) - if isinstance(global_delivery_override, dict): - if "sendProgress" in global_delivery_override: - send_progress = bool(global_delivery_override.get("sendProgress")) - if "sendToolHints" in global_delivery_override: - send_tool_hints = bool(global_delivery_override.get("sendToolHints")) - - channels_data = channels_override if channels_override is not None else _get_bot_channels_from_config(bot) - bot_data["send_progress"] = send_progress - bot_data["send_tool_hints"] = send_tool_hints - normalized_channels: List[Dict[str, Any]] = [] - for row in channels_data: - ctype = str(row.get("channel_type") or "").strip().lower() - if not ctype or ctype == "dashboard": - continue - normalized_channels.append( - { - "channel_type": ctype, - "external_app_id": str(row.get("external_app_id") or ""), - "app_secret": str(row.get("app_secret") or ""), - "internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)), - "is_active": bool(row.get("is_active", True)), - "extra_config": _normalize_channel_extra(row.get("extra_config")), - } - ) - config_manager.update_workspace( - bot_id=bot_id, - bot_data=bot_data, - channels=normalized_channels, - ) - _write_bot_resources( - bot_id, - bot_data.get("cpu_cores"), - bot_data.get("memory_mb"), - bot_data.get("storage_gb"), - ) - - -def _set_bot_provider_target(bot_id: str, target: ProviderTarget) -> None: - _set_provider_target_override(bot_id, target) - - -def _sync_bot_workspace_via_provider( - session: Session, - bot: BotInstance, - *, - target_override: Optional[ProviderTarget] = None, - channels_override: Optional[List[Dict[str, Any]]] = None, - global_delivery_override: Optional[Dict[str, Any]] = None, - runtime_overrides: Optional[Dict[str, Any]] = None, -) -> None: - bot_id = str(bot.id or "") - previous_override = _provider_target_overrides.get(bot_id) - wrote_target = False - try: - if target_override is not None: - _set_bot_provider_target(bot_id, target_override) - wrote_target = True - get_provision_provider(app.state, bot).sync_bot_workspace( - session=session, - bot_id=bot_id, - channels_override=channels_override, - global_delivery_override=global_delivery_override, - runtime_overrides=runtime_overrides, - ) - except Exception: - if wrote_target: - if previous_override is not None: - _set_provider_target_override(bot_id, previous_override) - else: - _clear_provider_target_override(bot_id) - raise - -def reconcile_image_registry(session: Session): - """Only reconcile status for images explicitly registered in DB.""" - db_images = session.exec(select(NanobotImage)).all() - for img in db_images: - if docker_manager.has_image(img.tag): - try: - docker_img = docker_manager.client.images.get(img.tag) if docker_manager.client else None - img.image_id = docker_img.id if docker_img else img.image_id - except Exception: - pass - img.status = "READY" - else: - img.status = "UNKNOWN" - session.add(img) - - session.commit() - - -def _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 _skills_root(bot_id: str) -> str: - return os.path.join(_workspace_root(bot_id), "skills") - - -def _is_valid_top_level_skill_name(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(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 f: - for line in f: - text = line.strip() - if text and not text.startswith("#"): - return text[:240] - except Exception: - continue - return "" - - -def _list_workspace_skills(bot_id: str) -> 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 _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 = _skills_root(bot_id) - if not os.path.isdir(root): - return [] - rows: List[Dict[str, Any]] = [] - names = sorted(os.listdir(root), key=lambda n: (not os.path.isdir(os.path.join(root, n)), n.lower())) - for name in names: - if not name or name.startswith("."): - continue - if not _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": _read_skill_description(abs_path), - } - ) - return rows - - -def _skill_market_root() -> str: - return os.path.abspath(os.path.join(DATA_ROOT, "skills")) - - -def _parse_json_string_list(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(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(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(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 _is_ignored_skill_zip_top_level(first): - continue - if _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 fh: - preview = fh.read(4096).decode("utf-8", errors="ignore") - description = _read_description_from_text(preview) - if description: - break - except Exception: - continue - return { - "entry_names": entry_names, - "description": description, - } - - -def _sanitize_skill_market_key(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(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(session: Session, preferred_key: str, exclude_id: Optional[int] = None) -> str: - base_key = _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( - session: Session, - filename: str, - *, - exclude_filename: Optional[str] = None, - exclude_id: Optional[int] = None, -) -> str: - root = _skill_market_root() - os.makedirs(root, exist_ok=True) - safe_name = _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( - session: Session, - upload: UploadFile, - *, - exclude_filename: Optional[str] = None, - exclude_id: Optional[int] = None, -) -> Dict[str, Any]: - root = _skill_market_root() - os.makedirs(root, exist_ok=True) - - incoming_name = _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 = _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 = _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( - 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(_skill_market_root(), str(item.zip_filename or "")) - entry_names = _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 _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 _install_skill_zip_into_workspace(bot_id: str, zip_path: str) -> Dict[str, Any]: - if _resolve_edge_state_context(bot_id) is not None: - raise HTTPException( - status_code=400, - detail="Edge bot skill install by zip is disabled here. Use edge workspace upload/deploy flow.", - ) - try: - archive = zipfile.ZipFile(zip_path) - except Exception as exc: - raise HTTPException(status_code=400, detail="Invalid zip file") from exc - - skills_root = _skills_root(bot_id) - os.makedirs(skills_root, exist_ok=True) - - installed: List[str] = [] - with archive: - members = archive.infolist() - file_members = [m for m in members if not m.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 _is_ignored_skill_zip_top_level(first): - continue - if not _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") - - 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)}") - - with tempfile.TemporaryDirectory(prefix=".skill_upload_", dir=skills_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) - - 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": _list_workspace_skills(bot_id), - } - - -def _cron_store_path(bot_id: str) -> str: - return os.path.join(_bot_data_root(bot_id), "cron", "jobs.json") - - -def _env_store_path(bot_id: str) -> str: - return os.path.join(_bot_data_root(bot_id), "env.json") - - -def _sessions_root(bot_id: str) -> str: - return os.path.join(_workspace_root(bot_id), "sessions") - - -def _clear_bot_sessions(bot_id: str) -> int: - """Remove persisted session files for the bot workspace.""" - root = _sessions_root(bot_id) - if not os.path.isdir(root): - return 0 - deleted = 0 - for name in os.listdir(root): - path = os.path.join(root, name) - if not os.path.isfile(path): - continue - if not name.lower().endswith(".jsonl"): - continue - try: - os.remove(path) - deleted += 1 - except Exception: - continue - return deleted - - -def _clear_bot_dashboard_direct_session(bot_id: str) -> Dict[str, Any]: - """Truncate the dashboard:direct session file while preserving the workspace session root.""" - root = _sessions_root(bot_id) - os.makedirs(root, exist_ok=True) - path = os.path.join(root, "dashboard_direct.jsonl") - existed = os.path.exists(path) - with open(path, "w", encoding="utf-8"): - pass - return {"path": path, "existed": existed} - - -def _read_env_store(bot_id: str) -> Dict[str, str]: - if _resolve_edge_state_context(bot_id) is not None: - data = _read_edge_state_data(bot_id=bot_id, state_key="env", default_payload={}) - return _normalize_env_params(data) - path = _env_store_path(bot_id) - if not os.path.isfile(path): - return {} - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return _normalize_env_params(data) - except Exception: - return {} - - -def _write_env_store(bot_id: str, env_params: Dict[str, str]) -> None: - normalized_env = _normalize_env_params(env_params) - if _write_edge_state_data(bot_id=bot_id, state_key="env", data=normalized_env): - return - path = _env_store_path(bot_id) - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(normalized_env, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -local_provision_provider = LocalProvisionProvider(sync_workspace_func=_sync_workspace_channels) -local_runtime_provider = LocalRuntimeProvider( - docker_manager=docker_manager, - on_state_change=docker_callback, - provision_provider=local_provision_provider, - read_runtime_snapshot=_read_bot_runtime_snapshot, - resolve_env_params=_resolve_bot_env_params, - write_env_store=_write_env_store, - invalidate_bot_cache=_invalidate_bot_detail_cache, - record_agent_loop_ready_warning=_record_agent_loop_ready_warning, - 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=_read_bot_runtime_snapshot, - 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=_read_bot_runtime_snapshot, - 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", -) -provider_registry = ProviderRegistry() -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=ProviderTarget(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=ProviderTarget(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 _ensure_provider_target_supported(target: ProviderTarget) -> None: - key = provider_registry.resolve_bundle_key(target) - if key is None: - raise HTTPException(status_code=400, detail=f"Execution target is not supported yet: {target.key}") - - -def _read_cron_store(bot_id: str) -> Dict[str, Any]: - if _resolve_edge_state_context(bot_id) is not None: - data = _read_edge_state_data( - bot_id=bot_id, - state_key="cron", - default_payload={"version": 1, "jobs": []}, - ) - if not isinstance(data, dict): - return {"version": 1, "jobs": []} - 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 = _cron_store_path(bot_id) - if not os.path.isfile(path): - return {"version": 1, "jobs": []} - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - if not isinstance(data, dict): - return {"version": 1, "jobs": []} - jobs = data.get("jobs") - if not isinstance(jobs, list): - data["jobs"] = [] - if "version" not in data: - data["version"] = 1 - return data - except Exception: - return {"version": 1, "jobs": []} - - -def _write_cron_store(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 _write_edge_state_data(bot_id=bot_id, state_key="cron", data=normalized_store): - return - path = _cron_store_path(bot_id) - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(normalized_store, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -def _resolve_workspace_path(bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]: - root = _workspace_root(bot_id) - rel = (rel_path or "").strip().replace("\\", "/") - target = os.path.abspath(os.path.join(root, rel)) - if os.path.commonpath([root, target]) != root: - raise HTTPException(status_code=400, detail="invalid workspace path") - return root, target - - -def _calc_dir_size_bytes(path: str) -> int: - total = 0 - if not os.path.exists(path): - return 0 - for root, _, files in os.walk(path): - for filename in files: - try: - file_path = os.path.join(root, filename) - if os.path.islink(file_path): - continue - total += os.path.getsize(file_path) - except Exception: - continue - return max(0, total) - - -def _is_image_attachment_path(path: str) -> bool: - lower = str(path or "").strip().lower() - return lower.endswith(".png") or lower.endswith(".jpg") or lower.endswith(".jpeg") or lower.endswith(".webp") - - -def _is_video_attachment_path(path: str) -> bool: - lower = str(path or "").strip().lower() - return ( - lower.endswith(".mp4") - or lower.endswith(".mov") - or lower.endswith(".m4v") - or lower.endswith(".webm") - or lower.endswith(".mkv") - or lower.endswith(".avi") - ) - - -def _is_visual_attachment_path(path: str) -> bool: - return _is_image_attachment_path(path) or _is_video_attachment_path(path) - - -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.state.bot_command_service = bot_command_service -app.state.workspace_service = workspace_service -app.state.runtime_service = runtime_service - - -def _workspace_stat_ctime_iso(stat: os.stat_result) -> str: - ts = getattr(stat, "st_birthtime", None) - if ts is None: - ts = getattr(stat, "st_ctime", None) - try: - return datetime.utcfromtimestamp(float(ts)).isoformat() + "Z" - except Exception: - return datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z" - - -def _build_workspace_tree(path: str, root: str, depth: int) -> List[Dict[str, Any]]: - rows: List[Dict[str, Any]] = [] - try: - names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower())) - except FileNotFoundError: - return rows - - for name in names: - if name in {".DS_Store"}: - continue - abs_path = os.path.join(path, name) - rel_path = os.path.relpath(abs_path, root).replace("\\", "/") - stat = os.stat(abs_path) - base: Dict[str, Any] = { - "name": name, - "path": rel_path, - "ctime": _workspace_stat_ctime_iso(stat), - "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", - } - if os.path.isdir(abs_path): - node = {**base, "type": "dir"} - if depth > 0: - node["children"] = _build_workspace_tree(abs_path, root, depth - 1) - rows.append(node) - continue - rows.append( - { - **base, - "type": "file", - "size": stat.st_size, - "ext": os.path.splitext(name)[1].lower(), - } - ) - return rows - - -def _list_workspace_dir(path: str, root: str) -> List[Dict[str, Any]]: - rows: List[Dict[str, Any]] = [] - names = sorted(os.listdir(path), key=lambda v: (not os.path.isdir(os.path.join(path, v)), v.lower())) - for name in names: - if name in {".DS_Store"}: - continue - abs_path = os.path.join(path, name) - rel_path = os.path.relpath(abs_path, root).replace("\\", "/") - stat = os.stat(abs_path) - rows.append( - { - "name": name, - "path": rel_path, - "type": "dir" if os.path.isdir(abs_path) else "file", - "size": stat.st_size if os.path.isfile(abs_path) else None, - "ext": os.path.splitext(name)[1].lower() if os.path.isfile(abs_path) else "", - "ctime": _workspace_stat_ctime_iso(stat), - "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", - } - ) - return rows - - -def _list_workspace_dir_recursive(path: str, root: str) -> List[Dict[str, Any]]: - rows: List[Dict[str, Any]] = [] - for walk_root, dirnames, filenames in os.walk(path): - dirnames.sort(key=lambda v: v.lower()) - filenames.sort(key=lambda v: v.lower()) - - for name in dirnames: - if name in {".DS_Store"}: - continue - abs_path = os.path.join(walk_root, name) - rel_path = os.path.relpath(abs_path, root).replace("\\", "/") - stat = os.stat(abs_path) - rows.append( - { - "name": name, - "path": rel_path, - "type": "dir", - "size": None, - "ext": "", - "ctime": _workspace_stat_ctime_iso(stat), - "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", - } - ) - - for name in filenames: - if name in {".DS_Store"}: - continue - abs_path = os.path.join(walk_root, name) - rel_path = os.path.relpath(abs_path, root).replace("\\", "/") - stat = os.stat(abs_path) - rows.append( - { - "name": name, - "path": rel_path, - "type": "file", - "size": stat.st_size, - "ext": os.path.splitext(name)[1].lower(), - "ctime": _workspace_stat_ctime_iso(stat), - "mtime": datetime.utcfromtimestamp(stat.st_mtime).isoformat() + "Z", - } - ) - - rows.sort(key=lambda v: (v.get("type") != "dir", str(v.get("path", "")).lower())) - return rows - - -@app.get("/api/images", response_model=List[NanobotImage]) -def list_images(session: Session = Depends(get_session)): - cached = cache.get_json(_cache_key_images()) - if isinstance(cached, list) and all(isinstance(row, dict) for row in cached): - return cached - if isinstance(cached, list): - _invalidate_images_cache() - reconcile_image_registry(session) - rows = session.exec(select(NanobotImage)).all() - payload = [row.model_dump() for row in rows] - cache.set_json(_cache_key_images(), payload, ttl=60) - return payload - - -@app.delete("/api/images/{tag:path}") -def delete_image(tag: str, session: Session = Depends(get_session)): - 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() - _invalidate_images_cache() - return {"status": "deleted"} - - -@app.get("/api/docker-images") -def list_docker_images(repository: str = "nanobot-base"): - rows = docker_manager.list_images_by_repo(repository) - return rows - - -@app.post("/api/images/register") -def register_image(payload: dict, session: Session = Depends(get_session)): - tag = (payload.get("tag") or "").strip() - source_dir = (payload.get("source_dir") or "manual").strip() or "manual" - if not tag: - raise HTTPException(status_code=400, detail="tag is required") - - if not 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 = docker_manager.client.images.get(tag) if 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) - _invalidate_images_cache() - return row - - -@app.post("/api/providers/test") -async def test_provider(payload: dict): - provider = (payload.get("provider") or "").strip() - api_key = (payload.get("api_key") or "").strip() - model = (payload.get("model") or "").strip() - api_base = (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 = _provider_defaults(provider) - base = (api_base or default_base).rstrip("/") - - if normalized_provider not in {"openrouter", "dashscope", "kimi", "minimax", "openai", "deepseek"}: - 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 m for m 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 e: - return { - "ok": False, - "provider": normalized_provider, - "endpoint": url, - "detail": str(e), - } - - -def _require_ready_image(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 docker_manager.has_image(normalized_tag): - raise HTTPException(status_code=400, detail=f"Docker image not found locally: {normalized_tag}") - return image_row - - -@app.post("/api/bots") -def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session)): - normalized_bot_id = str(payload.id or "").strip() - if not normalized_bot_id: - raise HTTPException(status_code=400, detail="Bot ID is required") - if not 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 = _normalize_env_params(payload.env_params) - try: - normalized_env_params["TZ"] = _normalize_system_timezone(payload.system_timezone) - except ValueError as exc: - raise HTTPException(status_code=400, detail=str(exc)) from exc - provider_target = normalize_provider_target( - { - "node_id": payload.node_id, - "transport_kind": payload.transport_kind, - "runtime_kind": payload.runtime_kind, - "core_adapter": payload.core_adapter, - }, - fallback=_provider_target_from_node(payload.node_id) or _default_provider_target(), - ) - _ensure_provider_target_supported(provider_target) - normalized_image_tag = str(payload.image_tag or "").strip() - if provider_target.runtime_kind == "docker": - _require_ready_image( - session, - normalized_image_tag, - require_local_image=True, - ) - - bot = BotInstance( - id=normalized_bot_id, - name=payload.name, - enabled=bool(payload.enabled) if payload.enabled is not None else True, - access_password=str(payload.access_password or ""), - 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 = _normalize_resource_limits(payload.cpu_cores, payload.memory_mb, payload.storage_gb) - workspace_synced = True - sync_error_detail = "" - try: - _write_env_store(normalized_bot_id, normalized_env_params) - _sync_bot_workspace_via_provider( - session, - bot, - target_override=provider_target, - channels_override=_normalize_initial_channels(normalized_bot_id, payload.channels), - global_delivery_override={ - "sendProgress": bool(payload.send_progress) if payload.send_progress is not None else False, - "sendToolHints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, - }, - runtime_overrides={ - "llm_provider": payload.llm_provider, - "llm_model": payload.llm_model, - "api_key": payload.api_key, - "api_base": payload.api_base or "", - "temperature": payload.temperature, - "top_p": payload.top_p, - "max_tokens": payload.max_tokens, - "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": payload.system_prompt or payload.soul_md or DEFAULT_SOUL_MD, - "soul_md": payload.soul_md or payload.system_prompt or DEFAULT_SOUL_MD, - "agents_md": payload.agents_md or DEFAULT_AGENTS_MD, - "user_md": payload.user_md or DEFAULT_USER_MD, - "tools_md": payload.tools_md or DEFAULT_TOOLS_MD, - "identity_md": payload.identity_md or DEFAULT_IDENTITY_MD, - "send_progress": bool(payload.send_progress) if payload.send_progress is not None else False, - "send_tool_hints": bool(payload.send_tool_hints) if payload.send_tool_hints is not None else False, - }, - ) - except Exception as exc: - if is_expected_edge_offline_error(exc): - workspace_synced = False - sync_error_detail = summarize_edge_exception(exc) - 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 = summarize_edge_exception(exc) - try: - doomed = session.get(BotInstance, normalized_bot_id) - if doomed is not None: - session.delete(doomed) - session.commit() - _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) - 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: - 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() - _invalidate_bot_detail_cache(normalized_bot_id) - return _serialize_bot(bot) - - -@app.get("/api/bots") -def list_bots(request: Request, session: Session = Depends(get_session)): - cached = cache.get_json(_cache_key_bots_list()) - if isinstance(cached, list): - return cached - bots = session.exec(select(BotInstance)).all() - dirty = False - for bot in bots: - previous_status = str(bot.docker_status or "").upper() - previous_state = str(bot.current_state or "") - actual_status = _refresh_bot_runtime_status(request.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 = [_serialize_bot_list_item(bot) for bot in bots] - cache.set_json(_cache_key_bots_list(), rows, ttl=30) - return rows - - -@app.get("/api/bots/{bot_id}") -def get_bot_detail(bot_id: str, request: Request, session: Session = Depends(get_session)): - cached = cache.get_json(_cache_key_bot_detail(bot_id)) - if isinstance(cached, dict): - return cached - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - previous_status = str(bot.docker_status or "").upper() - previous_state = str(bot.current_state or "") - actual_status = _refresh_bot_runtime_status(request.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 = _serialize_bot(bot) - cache.set_json(_cache_key_bot_detail(bot_id), row, ttl=30) - return row - - -@app.post("/api/bots/{bot_id}/auth/login") -def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - configured = str(bot.access_password or "").strip() - if not configured: - return {"ok": True, "enabled": False, "bot_id": bot_id} - - candidate = str(payload.password or "").strip() - if not candidate: - raise HTTPException(status_code=401, detail="Bot access password required") - if candidate != configured: - raise HTTPException(status_code=401, detail="Invalid bot access password") - return {"ok": True, "enabled": True, "bot_id": bot_id} - - -@app.get("/api/bots/{bot_id}/resources") -def get_bot_resources(bot_id: str, request: Request, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - configured = _read_bot_resources(bot_id) - try: - runtime = get_runtime_provider(request.app.state, bot).get_resource_snapshot(bot_id=bot_id) - except Exception as exc: - log_edge_failure( - 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 = _resolve_bot_provider_target_for_instance(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 = _workspace_root(bot_id) - workspace_bytes = _calc_dir_size_bytes(workspace_root) - elif target.transport_kind != "edge": - workspace_root = _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", - } - - -@app.put("/api/bots/{bot_id}") -def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - 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 = _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 - - # Never allow empty placeholders to overwrite existing runtime model settings. - 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 = _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", "access_password", "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 = _resolve_bot_env_params(bot_id) - next_env_params = dict(previous_env_params) - if env_params is not None: - next_env_params = _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")) - - _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: - _write_env_store(bot_id, next_env_params) - if next_target is not None: - _apply_provider_target_to_bot(bot, next_target) - session.add(bot) - session.commit() - except Exception: - session.rollback() - if previous_env_params is not None: - _write_env_store(bot_id, previous_env_params) - raise - session.refresh(bot) - _invalidate_bot_detail_cache(bot_id) - return _serialize_bot(bot) - - -@app.post("/api/bots/{bot_id}/deploy") -async def deploy_bot(bot_id: str, payload: BotDeployRequest, request: Request, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - actual_status = _refresh_bot_runtime_status(request.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 = _resolve_bot_provider_target_for_instance(bot) - next_target_base = _provider_target_from_node(payload.node_id) - if next_target_base is None: - raise HTTPException(status_code=400, detail=f"Managed node not found: {payload.node_id}") - next_target = normalize_provider_target( - { - "node_id": payload.node_id, - "runtime_kind": payload.runtime_kind, - }, - fallback=next_target_base, - ) - _ensure_provider_target_supported(next_target) - - existing_image_tag = str(bot.image_tag or "").strip() - requested_image_tag = str(payload.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": - _require_ready_image( - session, - requested_image_tag, - require_local_image=True, - ) - - _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 - _apply_provider_target_to_bot(bot, next_target) - bot.updated_at = datetime.utcnow() - session.add(bot) - record_activity_event( - session, - bot_id, - "bot_deployed", - channel="system", - detail=( - f"Bot {bot_id} deployed to {_node_display_name(next_target.node_id)}" - if target_changed - else f"Bot {bot_id} redeployed with image {requested_image_tag}" - ), - metadata={ - "previous_target": _serialize_provider_target_summary(current_target), - "next_target": _serialize_provider_target_summary(next_target), - "previous_image_tag": previous_image_tag, - "image_tag": requested_image_tag, - "auto_start": bool(payload.auto_start), - }, - ) - session.commit() - session.refresh(bot) - - started = False - if bool(payload.auto_start): - await runtime_service.start_bot(app_state=request.app.state, session=session, bot=bot) - session.refresh(bot) - started = True - - _invalidate_bot_detail_cache(bot_id) - return { - "status": "deployed", - "bot": _serialize_bot(bot), - "started": started, - "image_tag": requested_image_tag, - "previous_image_tag": previous_image_tag, - "previous_target": _serialize_provider_target_summary(current_target), - "next_target": _serialize_provider_target_summary(next_target), - } - - -@app.post("/api/bots/{bot_id}/start") -async def start_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return await runtime_service.start_bot(app_state=request.app.state, session=session, bot=bot) - - -@app.post("/api/bots/{bot_id}/stop") -def stop_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return runtime_service.stop_bot(app_state=request.app.state, session=session, bot=bot) - - -@app.post("/api/bots/{bot_id}/enable") -def enable_bot(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - bot.enabled = True - session.add(bot) - record_activity_event(session, bot_id, "bot_enabled", channel="system", detail=f"Bot {bot_id} enabled") - session.commit() - _invalidate_bot_detail_cache(bot_id) - return {"status": "enabled", "enabled": True} - - -@app.post("/api/bots/{bot_id}/disable") -def disable_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - try: - runtime_service.stop_bot(app_state=request.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) - record_activity_event(session, bot_id, "bot_disabled", channel="system", detail=f"Bot {bot_id} disabled") - session.commit() - _invalidate_bot_detail_cache(bot_id) - return {"status": "disabled", "enabled": False} - - -@app.post("/api/bots/{bot_id}/deactivate") -def deactivate_bot(bot_id: str, request: Request, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - try: - runtime_service.stop_bot(app_state=request.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) - record_activity_event(session, bot_id, "bot_deactivated", channel="system", detail=f"Bot {bot_id} deactivated") - session.commit() - _invalidate_bot_detail_cache(bot_id) - return {"status": "deactivated"} - - -@app.delete("/api/bots/{bot_id}") -def delete_bot(bot_id: str, request: Request, delete_workspace: bool = True, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - target = _resolve_bot_provider_target_for_instance(bot) - try: - runtime_service.stop_bot(app_state=request.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(_node_metadata(target.node_id).get("workspace_root") or "").strip() or None - purge_result = _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: - log_edge_failure( - 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() - _clear_provider_target_override(bot_id) - - _invalidate_bot_detail_cache(bot_id) - _invalidate_bot_messages_cache(bot_id) - return {"status": "deleted", "workspace_deleted": workspace_deleted} - - -@app.get("/api/bots/{bot_id}/channels") -def list_bot_channels(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return _get_bot_channels_from_config(bot) - - -@app.get("/api/platform/skills") -def list_skill_market(session: Session = Depends(get_session)): - 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 [ - _serialize_skill_market_item(item, install_count=install_count_by_skill.get(int(item.id or 0), 0)) - for item in items - ] - - -@app.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), -): - upload_meta = await _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 = _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 _serialize_skill_market_item(item, install_count=0) - except Exception: - target_path = os.path.join(_skill_market_root(), upload_meta["zip_filename"]) - if os.path.exists(target_path): - os.remove(target_path) - raise - - -@app.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), -): - 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 _store_skill_market_zip_upload( - session, - file, - exclude_filename=old_filename or None, - exclude_id=item.id, - ) - - next_key = _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(_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 _serialize_skill_market_item(item, install_count=install_count) - - -@app.delete("/api/platform/skills/{skill_id}") -def delete_skill_market_item(skill_id: int, session: Session = Depends(get_session)): - 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(_skill_market_root(), zip_filename) - if os.path.exists(zip_path): - os.remove(zip_path) - return {"status": "deleted", "id": skill_id} - - -@app.get("/api/bots/{bot_id}/skills") -def list_bot_skills(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return _list_workspace_skills(bot_id) - - -@app.get("/api/bots/{bot_id}/skill-market") -def list_bot_skill_market(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - 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 _list_workspace_skills(bot_id)} - return [ - _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 _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 _parse_json_string_list(install_lookup[int(item.id or 0)].installed_entries_json) - ), - ) - for item in items - ] - - -@app.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)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - 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(_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 = _install_skill_zip_into_workspace(bot_id, zip_path) - 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": _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 - - -@app.get("/api/bots/{bot_id}/tools-config") -def get_bot_tools_config(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return { - "bot_id": bot_id, - "tools_config": {}, - "managed_by_dashboard": False, - "hint": "Tools config is disabled in dashboard. Configure tool-related env vars manually.", - } - - -@app.put("/api/bots/{bot_id}/tools-config") -def update_bot_tools_config(bot_id: str, payload: BotToolsConfigUpdateRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - raise HTTPException( - status_code=400, - detail="Tools config is no longer managed by dashboard. Please set required env vars manually.", - ) - - -@app.get("/api/bots/{bot_id}/mcp-config") -def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - config_data = _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 = _normalize_mcp_servers(tools_cfg.get("mcpServers")) - return { - "bot_id": bot_id, - "mcp_servers": mcp_servers, - "locked_servers": [], - "restart_required": True, - } - - -@app.put("/api/bots/{bot_id}/mcp-config") -def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - config_data = _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 = _normalize_mcp_servers(payload.mcp_servers or {}) - current_mcp_servers = tools_cfg.get("mcpServers") - merged_mcp_servers = _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 = _sanitize_mcp_servers_in_config_data(config_data) - _write_bot_config(bot_id, config_data) - _invalidate_bot_detail_cache(bot_id) - return { - "status": "updated", - "bot_id": bot_id, - "mcp_servers": _normalize_mcp_servers(sanitized_after_save), - "locked_servers": [], - "restart_required": True, - } - - -@app.get("/api/bots/{bot_id}/env-params") -def get_bot_env_params(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return { - "bot_id": bot_id, - "env_params": _read_env_store(bot_id), - } - - -@app.put("/api/bots/{bot_id}/env-params") -def update_bot_env_params(bot_id: str, payload: BotEnvParamsUpdateRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - normalized = _normalize_env_params(payload.env_params) - _write_env_store(bot_id, normalized) - _invalidate_bot_detail_cache(bot_id) - return { - "status": "updated", - "bot_id": bot_id, - "env_params": normalized, - "restart_required": True, - } - - -@app.post("/api/bots/{bot_id}/skills/upload") -async def upload_bot_skill_zip(bot_id: str, file: UploadFile = File(...), session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - 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 = _install_skill_zip_into_workspace(bot_id, tmp_zip_path) - 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"], - } - - -@app.delete("/api/bots/{bot_id}/skills/{skill_name}") -def delete_bot_skill(bot_id: str, skill_name: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - 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 _is_valid_top_level_skill_name(name): - raise HTTPException(status_code=400, detail="Invalid skill name") - root = _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} - - -@app.post("/api/bots/{bot_id}/channels") -def create_bot_channel(bot_id: str, payload: ChannelConfigRequest, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - ctype = (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 = _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": (payload.external_app_id or "").strip() or f"{ctype}-{bot_id}", - "app_secret": (payload.app_secret or "").strip(), - "internal_port": max(1, min(int(payload.internal_port or 8080), 65535)), - "is_active": bool(payload.is_active), - "extra_config": _normalize_channel_extra(payload.extra_config), - "locked": False, - } - - config_data = _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] = _channel_api_to_cfg(new_row) - _write_bot_config(bot_id, config_data) - _sync_bot_workspace_via_provider(session, bot) - _invalidate_bot_detail_cache(bot_id) - return new_row - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - channel_key = str(channel_id or "").strip().lower() - rows = _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"] = _normalize_channel_extra(update_data.get("extra_config")) - row["channel_type"] = new_type - row["id"] = new_type - row["locked"] = new_type == "dashboard" - - config_data = _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 = _read_global_delivery_flags(channels_cfg) - if new_type == "dashboard": - extra = _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] = _channel_api_to_cfg(row) - _write_bot_config(bot_id, config_data) - session.commit() - _sync_bot_workspace_via_provider(session, bot) - _invalidate_bot_detail_cache(bot_id) - return row - - -@app.delete("/api/bots/{bot_id}/channels/{channel_id}") -def delete_bot_channel(bot_id: str, channel_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - channel_key = str(channel_id or "").strip().lower() - rows = _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 = _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) - _write_bot_config(bot_id, config_data) - session.commit() - _sync_bot_workspace_via_provider(session, bot) - _invalidate_bot_detail_cache(bot_id) - return {"status": "deleted"} - - -@app.post("/api/bots/{bot_id}/command") -def send_command( - bot_id: str, - payload: CommandRequest, - request: Request, - session: Session = Depends(get_session), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return runtime_service.send_command( - app_state=request.app.state, - session=session, - bot_id=bot_id, - bot=bot, - payload=payload, - ) - - -@app.get("/api/bots/{bot_id}/messages") -def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - safe_limit = max(1, min(int(limit), 500)) - cached = cache.get_json(_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 = [_serialize_bot_message_row(bot_id, row) for row in ordered] - cache.set_json(_cache_key_bot_messages(bot_id, safe_limit), payload, ttl=30) - return payload - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - configured_limit = 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 = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id) - cached = 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 = [_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, - } - cache.set_json(cache_key, payload, ttl=30) - return payload - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - - utc_start, utc_end = _resolve_local_day_range(date, tz_offset_minutes) - configured_limit = max(60, 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": [_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, - } - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - 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(payload.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() - _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, - } - - -@app.delete("/api/bots/{bot_id}/messages") -def clear_bot_messages(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return runtime_service.clear_messages(app_state=app.state, session=session, bot=bot) - - -@app.post("/api/bots/{bot_id}/sessions/dashboard-direct/clear") -def clear_bot_dashboard_direct_session(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return runtime_service.clear_dashboard_direct_session(app_state=app.state, session=session, bot=bot) - - -@app.get("/api/bots/{bot_id}/logs") -def get_bot_logs(bot_id: str, tail: int = 300, request: Request = None, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return runtime_service.get_logs(app_state=request.app.state, bot=bot, tail=tail) - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return workspace_service.list_tree(app_state=request.app.state, bot=bot, path=path, recursive=recursive) - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return workspace_service.read_file(app_state=request.app.state, bot=bot, path=path, max_bytes=max_bytes) - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return workspace_service.write_markdown( - app_state=request.app.state, - bot=bot, - path=path, - content=str(payload.content or ""), - ) - -def _serve_workspace_file( - bot_id: str, - path: str, - download: bool, - request: Request, - session: Session, - *, - public: bool = False, - redirect_html_to_raw: bool = False, -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return workspace_service.serve_file( - app_state=request.app.state, - bot=bot, - path=path, - download=download, - request=request, - public=public, - redirect_html_to_raw=redirect_html_to_raw, - ) - - -@app.get("/api/bots/{bot_id}/cron/jobs") -def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - store = _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 v: int(((v.get("state") or {}).get("nextRunAtMs")) or 2**62)) - return {"bot_id": bot_id, "version": int(store.get("version", 1) or 1), "jobs": rows} - - -@app.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)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - store = _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) - _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs}) - return {"status": "stopped", "job_id": job_id} - - -@app.delete("/api/bots/{bot_id}/cron/jobs/{job_id}") -def delete_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - store = _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") - _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": kept}) - return {"status": "deleted", "job_id": job_id} - - -@app.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 _serve_workspace_file( - bot_id=bot_id, - path=path, - download=download, - request=request, - session=session, - public=False, - redirect_html_to_raw=True, - ) - - -@app.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 _serve_workspace_file( - bot_id=bot_id, - path=path, - download=download, - request=request, - session=session, - public=True, - redirect_html_to_raw=True, - ) - - -@app.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 _serve_workspace_file( - bot_id=bot_id, - path=path, - download=download, - request=request, - session=session, - public=False, - redirect_html_to_raw=False, - ) - - -@app.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 _serve_workspace_file( - bot_id=bot_id, - path=path, - download=download, - request=request, - session=session, - public=True, - redirect_html_to_raw=False, - ) - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - return await workspace_service.upload_files(app_state=request.app.state, bot=bot, files=files, path=path) - - -@app.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), -): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - speech_settings = 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=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(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: - 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: - 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: - 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: - 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 - - -@app.websocket("/ws/monitor/{bot_id}") -async def websocket_endpoint(websocket: WebSocket, bot_id: str): - with Session(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 manager.connect(bot_id, websocket) - connected = True - except Exception as exc: - 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 - - 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: - # Client may drop before handshake settles; treat as benign disconnect. - msg = str(exc or "").lower() - if "need to call \"accept\" first" not in msg and "not connected" not in msg: - logger.exception("websocket runtime error bot_id=%s", bot_id) - except Exception: - logger.exception("websocket unexpected error bot_id=%s", bot_id) - finally: - if connected: - manager.disconnect(bot_id, websocket) +from app_factory import app def _main_server_options() -> tuple[str, int, bool, str, bool]: @@ -4827,10 +20,9 @@ if __name__ == "__main__": import uvicorn host, port, reload_flag, log_level, access_log = _main_server_options() - app_module = f"{os.path.splitext(os.path.basename(__file__))[0]}:app" if reload_flag: uvicorn.run( - app_module, + "main:app", host=host, port=port, reload=True, diff --git a/backend/models/sys_auth.py b/backend/models/sys_auth.py new file mode 100644 index 0000000..16062e4 --- /dev/null +++ b/backend/models/sys_auth.py @@ -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) diff --git a/backend/providers/runtime/local.py b/backend/providers/runtime/local.py index 05e5110..9bd953c 100644 --- a/backend/providers/runtime/local.py +++ b/backend/providers/runtime/local.py @@ -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): diff --git a/backend/requirements.txt b/backend/requirements.txt index e89a512..4c20261 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -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 diff --git a/backend/schemas/dashboard.py b/backend/schemas/dashboard.py new file mode 100644 index 0000000..2028d4e --- /dev/null +++ b/backend/schemas/dashboard.py @@ -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 diff --git a/backend/schemas/platform.py b/backend/schemas/platform.py index 86501d2..fc592bd 100644 --- a/backend/schemas/platform.py +++ b/backend/schemas/platform.py @@ -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 = "" diff --git a/backend/schemas/sys_auth.py b/backend/schemas/sys_auth.py new file mode 100644 index 0000000..96b4737 --- /dev/null +++ b/backend/schemas/sys_auth.py @@ -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() diff --git a/backend/services/app_lifecycle_service.py b/backend/services/app_lifecycle_service.py new file mode 100644 index 0000000..386f152 --- /dev/null +++ b/backend/services/app_lifecycle_service.py @@ -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) diff --git a/backend/services/bot_channel_service.py b/backend/services/bot_channel_service.py new file mode 100644 index 0000000..29af901 --- /dev/null +++ b/backend/services/bot_channel_service.py @@ -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"} diff --git a/backend/services/bot_config_state_service.py b/backend/services/bot_config_state_service.py new file mode 100644 index 0000000..f5c2bc0 --- /dev/null +++ b/backend/services/bot_config_state_service.py @@ -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) diff --git a/backend/services/bot_infra_service.py b/backend/services/bot_infra_service.py new file mode 100644 index 0000000..146cb73 --- /dev/null +++ b/backend/services/bot_infra_service.py @@ -0,0 +1,1089 @@ +import json +import os +import re +from typing import Any, Callable, Dict, List, Optional +from zoneinfo import ZoneInfo + +import httpx +from fastapi import HTTPException +from sqlmodel import Session + +from clients.edge.errors import log_edge_failure +from clients.edge.http import HttpEdgeClient +from core.config_manager import BotConfigManager +from models.bot import BotInstance +from providers.target import ProviderTarget +from services.node_registry_service import ManagedNode, NodeRegistryService + + +ReadEnvStore = Callable[[str], Dict[str, str]] +ReadBotRuntimeSnapshot = Callable[[BotInstance], Dict[str, Any]] +NormalizeMediaList = Callable[[Any, str], List[str]] +NormalizeProviderTarget = Callable[..., ProviderTarget] +ProviderTargetFromConfig = Callable[..., ProviderTarget] +ProviderTargetToDict = Callable[[ProviderTarget], Dict[str, Any]] +ResolveProviderBundleKey = Callable[[ProviderTarget], Optional[str]] +GetProvisionProvider = Callable[[Any, BotInstance], Any] + + +class BotInfraService: + _ENV_KEY_RE = re.compile(r"^[A-Z_][A-Z0-9_]{0,127}$") + + def __init__( + self, + *, + app: Any, + engine: Any, + config_manager: BotConfigManager, + node_registry_service: NodeRegistryService, + logger: Any, + bots_workspace_root: str, + default_soul_md: str, + default_agents_md: str, + default_user_md: str, + default_tools_md: str, + default_identity_md: str, + default_bot_system_timezone: str, + normalize_provider_target: NormalizeProviderTarget, + provider_target_from_config: ProviderTargetFromConfig, + provider_target_to_dict: ProviderTargetToDict, + resolve_provider_bundle_key: ResolveProviderBundleKey, + get_provision_provider: GetProvisionProvider, + read_env_store: ReadEnvStore, + read_bot_runtime_snapshot: ReadBotRuntimeSnapshot, + normalize_media_list: NormalizeMediaList, + ) -> None: + self._app = app + self._engine = engine + self._config_manager = config_manager + self._node_registry_service = node_registry_service + self._logger = logger + self._bots_workspace_root = bots_workspace_root + 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._default_bot_system_timezone = default_bot_system_timezone + self._normalize_provider_target = normalize_provider_target + self._provider_target_from_config = provider_target_from_config + self._provider_target_to_dict = provider_target_to_dict + self._resolve_provider_bundle_key = resolve_provider_bundle_key + self._get_provision_provider = get_provision_provider + self._read_env_store = read_env_store + self._read_bot_runtime_snapshot = read_bot_runtime_snapshot + self._normalize_media_list = normalize_media_list + self._provider_target_overrides: Dict[str, ProviderTarget] = {} + + def config_json_path(self, bot_id: str) -> str: + return os.path.join(self.bot_data_root(bot_id), "config.json") + + def read_bot_config(self, bot_id: str) -> Dict[str, Any]: + if self.resolve_edge_state_context(bot_id) is not None: + data = self.read_edge_state_data(bot_id=bot_id, state_key="config", default_payload={}) + return data if isinstance(data, dict) else {} + path = self.config_json_path(bot_id) + if not os.path.isfile(path): + return {} + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + return data if isinstance(data, dict) else {} + except Exception: + return {} + + def write_bot_config(self, bot_id: str, config_data: Dict[str, Any]) -> None: + normalized = dict(config_data if isinstance(config_data, dict) else {}) + if self.write_edge_state_data(bot_id=bot_id, state_key="config", data=normalized): + return + path = self.config_json_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, file, ensure_ascii=False, indent=2) + os.replace(tmp_path, path) + + def default_provider_target(self) -> ProviderTarget: + return self._normalize_provider_target( + { + "node_id": getattr(self._app.state, "provider_default_node_id", None), + "transport_kind": getattr(self._app.state, "provider_default_transport_kind", None), + "runtime_kind": getattr(self._app.state, "provider_default_runtime_kind", None), + "core_adapter": getattr(self._app.state, "provider_default_core_adapter", None), + }, + fallback=ProviderTarget(), + ) + + def read_bot_provider_target( + self, + bot_id: str, + config_data: Optional[Dict[str, Any]] = None, + ) -> ProviderTarget: + normalized_bot_id = str(bot_id or "").strip() + if normalized_bot_id and normalized_bot_id in self._provider_target_overrides: + return self._provider_target_overrides[normalized_bot_id] + if normalized_bot_id: + with Session(self._engine) as session: + bot = session.get(BotInstance, normalized_bot_id) + if bot is not None: + return self._normalize_provider_target( + { + "node_id": getattr(bot, "node_id", None), + "transport_kind": getattr(bot, "transport_kind", None), + "runtime_kind": getattr(bot, "runtime_kind", None), + "core_adapter": getattr(bot, "core_adapter", None), + }, + fallback=self.default_provider_target(), + ) + raw_config = config_data if isinstance(config_data, dict) else self.read_bot_config(bot_id) + return self._provider_target_from_config(raw_config, fallback=self.default_provider_target()) + + def resolve_bot_provider_target_for_instance(self, bot: BotInstance) -> ProviderTarget: + normalized_bot_id = str(getattr(bot, "id", "") or "").strip() + if normalized_bot_id and normalized_bot_id in self._provider_target_overrides: + return self._provider_target_overrides[normalized_bot_id] + inline_values = { + "node_id": getattr(bot, "node_id", None), + "transport_kind": getattr(bot, "transport_kind", None), + "runtime_kind": getattr(bot, "runtime_kind", None), + "core_adapter": getattr(bot, "core_adapter", None), + } + if any(str(value or "").strip() for value in inline_values.values()): + return self._normalize_provider_target(inline_values, fallback=self.default_provider_target()) + return self.read_bot_provider_target(str(bot.id or "")) + + def set_provider_target_override(self, bot_id: str, target: ProviderTarget) -> None: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return + self._provider_target_overrides[normalized_bot_id] = target + + def clear_provider_target_override(self, bot_id: str) -> None: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return + self._provider_target_overrides.pop(normalized_bot_id, None) + + def clear_provider_target_overrides(self) -> None: + self._provider_target_overrides.clear() + + def apply_provider_target_to_bot(self, bot: BotInstance, target: ProviderTarget) -> None: + bot.node_id = target.node_id + bot.transport_kind = target.transport_kind + bot.runtime_kind = target.runtime_kind + bot.core_adapter = target.core_adapter + + def local_managed_node(self) -> ManagedNode: + return ManagedNode( + node_id="local", + display_name="Local Node", + base_url=str(os.getenv("LOCAL_EDGE_BASE_URL", "http://127.0.0.1:8010") or "http://127.0.0.1:8010").strip(), + enabled=True, + auth_token=str(os.getenv("EDGE_AUTH_TOKEN", "") or "").strip(), + metadata={ + "transport_kind": "edge", + "runtime_kind": "docker", + "core_adapter": "nanobot", + "workspace_root": str( + os.getenv("EDGE_WORKSPACE_ROOT", os.getenv("EDGE_BOTS_WORKSPACE_ROOT", "")) or "" + ).strip(), + "native_command": str(os.getenv("EDGE_NATIVE_COMMAND", "") or "").strip(), + "native_workdir": str(os.getenv("EDGE_NATIVE_WORKDIR", "") or "").strip(), + "native_sandbox_mode": str(os.getenv("EDGE_NATIVE_SANDBOX_MODE", "inherit") or "inherit").strip().lower(), + }, + ) + + def provider_target_from_node(self, node_id: Optional[str]) -> Optional[ProviderTarget]: + normalized = str(node_id or "").strip().lower() + if not normalized: + return None + node = self._node_registry_service.get_node(normalized) + if node is None: + return None + metadata = dict(node.metadata or {}) + return ProviderTarget( + node_id=node.node_id, + transport_kind=str(metadata.get("transport_kind") or "edge").strip().lower() or "edge", + runtime_kind=str(metadata.get("runtime_kind") or "docker").strip().lower() or "docker", + core_adapter=str(metadata.get("core_adapter") or "nanobot").strip().lower() or "nanobot", + ) + + def node_display_name(self, node_id: str) -> str: + node = self._node_registry_service.get_node(node_id) + if node is not None: + return str(node.display_name or node.node_id or node_id).strip() or str(node_id or "").strip() + return str(node_id or "").strip() + + def node_metadata(self, node_id: str) -> Dict[str, Any]: + node = self._node_registry_service.get_node(node_id) + if node is None: + return {} + return dict(node.metadata or {}) + + def serialize_provider_target_summary(self, target: ProviderTarget) -> Dict[str, Any]: + return { + **self._provider_target_to_dict(target), + "node_display_name": self.node_display_name(target.node_id), + } + + def resolve_edge_client(self, target: ProviderTarget) -> HttpEdgeClient: + try: + node = self._node_registry_service.require_node(target.node_id) + except ValueError as exc: + raise HTTPException(status_code=400, detail=str(exc)) from exc + return HttpEdgeClient( + node=node, + http_client_factory=lambda: httpx.Client(timeout=15.0, trust_env=False), + async_http_client_factory=lambda: httpx.AsyncClient(timeout=15.0, trust_env=False), + ) + + def resolve_edge_state_context(self, bot_id: str) -> Optional[tuple[HttpEdgeClient, Optional[str], str]]: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return None + with Session(self._engine) as session: + bot = session.get(BotInstance, normalized_bot_id) + if bot is None: + return None + target = self.resolve_bot_provider_target_for_instance(bot) + if str(target.transport_kind or "").strip().lower() != "edge": + return None + client = self.resolve_edge_client(target) + metadata = self.node_metadata(target.node_id) + workspace_root = str(metadata.get("workspace_root") or "").strip() or None + return client, workspace_root, target.node_id + + def read_edge_state_data( + self, + *, + bot_id: str, + state_key: str, + default_payload: Dict[str, Any], + ) -> Dict[str, Any]: + context = self.resolve_edge_state_context(bot_id) + if context is None: + return dict(default_payload) + client, workspace_root, node_id = context + try: + payload = client.read_state( + bot_id=bot_id, + state_key=state_key, + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + self._logger, + key=f"edge-state-read:{node_id}:{bot_id}:{state_key}", + exc=exc, + message=f"Failed to read edge state for bot_id={bot_id}, state_key={state_key}", + ) + return dict(default_payload) + data = payload.get("data") + if isinstance(data, dict): + return dict(data) + return dict(default_payload) + + def write_edge_state_data( + self, + *, + bot_id: str, + state_key: str, + data: Dict[str, Any], + ) -> bool: + context = self.resolve_edge_state_context(bot_id) + if context is None: + return False + client, workspace_root, node_id = context + try: + client.write_state( + bot_id=bot_id, + state_key=state_key, + data=dict(data or {}), + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + self._logger, + key=f"edge-state-write:{node_id}:{bot_id}:{state_key}", + exc=exc, + message=f"Failed to write edge state for bot_id={bot_id}, state_key={state_key}", + ) + raise + return True + + def resources_json_path(self, bot_id: str) -> str: + return os.path.join(self.bot_data_root(bot_id), "resources.json") + + def write_bot_resources(self, bot_id: str, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> None: + normalized = self.normalize_resource_limits(cpu_cores, memory_mb, storage_gb) + payload = { + "cpuCores": normalized["cpu_cores"], + "memoryMB": normalized["memory_mb"], + "storageGB": normalized["storage_gb"], + } + if self.write_edge_state_data(bot_id=bot_id, state_key="resources", data=payload): + return + path = self.resources_json_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(payload, file, ensure_ascii=False, indent=2) + os.replace(tmp_path, path) + + def read_legacy_resource_values( + self, + bot_id: str, + config_data: Optional[Dict[str, Any]] = None, + ) -> tuple[Any, Any, Any]: + cpu_raw: Any = None + memory_raw: Any = None + storage_raw: Any = None + + path = self.resources_json_path(bot_id) + if os.path.isfile(path): + try: + with open(path, "r", encoding="utf-8") as file: + data = json.load(file) + if isinstance(data, dict): + cpu_raw = data.get("cpuCores", data.get("cpu_cores")) + memory_raw = data.get("memoryMB", data.get("memory_mb")) + storage_raw = data.get("storageGB", data.get("storage_gb")) + except Exception: + pass + + if cpu_raw is None or memory_raw is None or storage_raw is None: + cfg = config_data if isinstance(config_data, dict) else self.read_bot_config(bot_id) + runtime_cfg = cfg.get("runtime") + if isinstance(runtime_cfg, dict): + resources_raw = runtime_cfg.get("resources") + if isinstance(resources_raw, dict): + if cpu_raw is None: + cpu_raw = resources_raw.get("cpuCores", resources_raw.get("cpu_cores")) + if memory_raw is None: + memory_raw = resources_raw.get("memoryMB", resources_raw.get("memory_mb")) + if storage_raw is None: + storage_raw = resources_raw.get("storageGB", resources_raw.get("storage_gb")) + return cpu_raw, memory_raw, storage_raw + + def read_bot_resources(self, bot_id: str, config_data: Optional[Dict[str, Any]] = None) -> Dict[str, Any]: + edge_context = self.resolve_edge_state_context(bot_id) + cpu_raw: Any = None + memory_raw: Any = None + storage_raw: Any = None + if edge_context is not None: + data = self.read_edge_state_data( + bot_id=bot_id, + state_key="resources", + default_payload={}, + ) + cpu_raw = data.get("cpuCores", data.get("cpu_cores")) + memory_raw = data.get("memoryMB", data.get("memory_mb")) + storage_raw = data.get("storageGB", data.get("storage_gb")) + if cpu_raw is None or memory_raw is None or storage_raw is None: + legacy_cpu, legacy_memory, legacy_storage = self.read_legacy_resource_values( + bot_id, + config_data=config_data, + ) + if cpu_raw is None: + cpu_raw = legacy_cpu + if memory_raw is None: + memory_raw = legacy_memory + if storage_raw is None: + storage_raw = legacy_storage + return self.normalize_resource_limits(cpu_raw, memory_raw, storage_raw) + + cpu_raw, memory_raw, storage_raw = self.read_legacy_resource_values(bot_id, config_data=config_data) + return self.normalize_resource_limits(cpu_raw, memory_raw, storage_raw) + + def migrate_bot_resources_store(self, bot_id: str) -> None: + if self.resolve_edge_state_context(bot_id) is not None: + return + + config_data = self.read_bot_config(bot_id) + runtime_cfg = config_data.get("runtime") + resources_raw: Dict[str, Any] = {} + if isinstance(runtime_cfg, dict): + legacy_raw = runtime_cfg.get("resources") + if isinstance(legacy_raw, dict): + resources_raw = legacy_raw + + path = self.resources_json_path(bot_id) + if not os.path.isfile(path): + self.write_bot_resources( + bot_id, + resources_raw.get("cpuCores", resources_raw.get("cpu_cores")), + resources_raw.get("memoryMB", resources_raw.get("memory_mb")), + resources_raw.get("storageGB", resources_raw.get("storage_gb")), + ) + + if isinstance(runtime_cfg, dict) and "resources" in runtime_cfg: + runtime_cfg.pop("resources", None) + if not runtime_cfg: + config_data.pop("runtime", None) + self.write_bot_config(bot_id, config_data) + + @staticmethod + def normalize_channel_extra(raw: Any) -> Dict[str, Any]: + if not isinstance(raw, dict): + return {} + return raw + + @staticmethod + def normalize_allow_from(raw: Any) -> List[str]: + rows: List[str] = [] + if isinstance(raw, list): + for item in raw: + text = str(item or "").strip() + if text and text not in rows: + rows.append(text) + if not rows: + return ["*"] + return rows + + def read_global_delivery_flags(self, channels_cfg: Any) -> tuple[bool, bool]: + if not isinstance(channels_cfg, dict): + return False, False + send_progress = channels_cfg.get("sendProgress") + send_tool_hints = channels_cfg.get("sendToolHints") + dashboard_cfg = channels_cfg.get("dashboard") + if isinstance(dashboard_cfg, dict): + if send_progress is None and "sendProgress" in dashboard_cfg: + send_progress = dashboard_cfg.get("sendProgress") + if send_tool_hints is None and "sendToolHints" in dashboard_cfg: + send_tool_hints = dashboard_cfg.get("sendToolHints") + return bool(send_progress), bool(send_tool_hints) + + def channel_cfg_to_api_dict(self, bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Dict[str, Any]: + normalized_type = str(ctype or "").strip().lower() + enabled = bool(cfg.get("enabled", True)) + port = max(1, min(int(cfg.get("port", 8080) or 8080), 65535)) + extra: Dict[str, Any] = {} + external_app_id = "" + app_secret = "" + + if normalized_type == "feishu": + external_app_id = str(cfg.get("appId") or "") + app_secret = str(cfg.get("appSecret") or "") + extra = { + "encryptKey": cfg.get("encryptKey", ""), + "verificationToken": cfg.get("verificationToken", ""), + "allowFrom": self.normalize_allow_from(cfg.get("allowFrom", [])), + } + elif normalized_type == "dingtalk": + external_app_id = str(cfg.get("clientId") or "") + app_secret = str(cfg.get("clientSecret") or "") + extra = {"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", []))} + elif normalized_type == "telegram": + app_secret = str(cfg.get("token") or "") + extra = { + "proxy": cfg.get("proxy", ""), + "replyToMessage": bool(cfg.get("replyToMessage", False)), + "allowFrom": self.normalize_allow_from(cfg.get("allowFrom", [])), + } + elif normalized_type == "slack": + external_app_id = str(cfg.get("botToken") or "") + app_secret = str(cfg.get("appToken") or "") + extra = { + "mode": cfg.get("mode", "socket"), + "replyInThread": bool(cfg.get("replyInThread", True)), + "groupPolicy": cfg.get("groupPolicy", "mention"), + "groupAllowFrom": cfg.get("groupAllowFrom", []), + "reactEmoji": cfg.get("reactEmoji", "eyes"), + } + elif normalized_type == "qq": + external_app_id = str(cfg.get("appId") or "") + app_secret = str(cfg.get("secret") or "") + extra = {"allowFrom": self.normalize_allow_from(cfg.get("allowFrom", []))} + elif normalized_type == "email": + extra = { + "consentGranted": bool(cfg.get("consentGranted", False)), + "imapHost": str(cfg.get("imapHost") or ""), + "imapPort": int(cfg.get("imapPort") or 993), + "imapUsername": str(cfg.get("imapUsername") or ""), + "imapPassword": str(cfg.get("imapPassword") or ""), + "imapMailbox": str(cfg.get("imapMailbox") or "INBOX"), + "imapUseSsl": bool(cfg.get("imapUseSsl", True)), + "smtpHost": str(cfg.get("smtpHost") or ""), + "smtpPort": int(cfg.get("smtpPort") or 587), + "smtpUsername": str(cfg.get("smtpUsername") or ""), + "smtpPassword": str(cfg.get("smtpPassword") or ""), + "smtpUseTls": bool(cfg.get("smtpUseTls", True)), + "smtpUseSsl": bool(cfg.get("smtpUseSsl", False)), + "fromAddress": str(cfg.get("fromAddress") or ""), + "autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)), + "pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30), + "markSeen": bool(cfg.get("markSeen", True)), + "maxBodyChars": int(cfg.get("maxBodyChars") or 12000), + "subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "), + "allowFrom": self.normalize_allow_from(cfg.get("allowFrom", [])), + } + else: + external_app_id = str( + cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "" + ) + app_secret = str( + cfg.get("appSecret") + or cfg.get("clientSecret") + or cfg.get("secret") + or cfg.get("token") + or cfg.get("appToken") + or "" + ) + extra = { + key: value + for key, value in cfg.items() + if key + not in { + "enabled", + "port", + "appId", + "clientId", + "botToken", + "externalAppId", + "appSecret", + "clientSecret", + "secret", + "token", + "appToken", + } + } + + return { + "id": normalized_type, + "bot_id": bot_id, + "channel_type": normalized_type, + "external_app_id": external_app_id, + "app_secret": app_secret, + "internal_port": port, + "is_active": enabled, + "extra_config": extra, + "locked": normalized_type == "dashboard", + } + + def channel_api_to_cfg(self, row: Dict[str, Any]) -> Dict[str, Any]: + ctype = str(row.get("channel_type") or "").strip().lower() + enabled = bool(row.get("is_active", True)) + extra = self.normalize_channel_extra(row.get("extra_config")) + external_app_id = str(row.get("external_app_id") or "") + app_secret = str(row.get("app_secret") or "") + port = max(1, min(int(row.get("internal_port") or 8080), 65535)) + + if ctype == "feishu": + return { + "enabled": enabled, + "appId": external_app_id, + "appSecret": app_secret, + "encryptKey": extra.get("encryptKey", ""), + "verificationToken": extra.get("verificationToken", ""), + "allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])), + } + if ctype == "dingtalk": + return { + "enabled": enabled, + "clientId": external_app_id, + "clientSecret": app_secret, + "allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])), + } + if ctype == "telegram": + return { + "enabled": enabled, + "token": app_secret, + "proxy": extra.get("proxy", ""), + "replyToMessage": bool(extra.get("replyToMessage", False)), + "allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])), + } + if ctype == "slack": + return { + "enabled": enabled, + "mode": extra.get("mode", "socket"), + "botToken": external_app_id, + "appToken": app_secret, + "replyInThread": bool(extra.get("replyInThread", True)), + "groupPolicy": extra.get("groupPolicy", "mention"), + "groupAllowFrom": extra.get("groupAllowFrom", []), + "reactEmoji": extra.get("reactEmoji", "eyes"), + } + if ctype == "qq": + return { + "enabled": enabled, + "appId": external_app_id, + "secret": app_secret, + "allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])), + } + if ctype == "email": + return { + "enabled": enabled, + "consentGranted": bool(extra.get("consentGranted", False)), + "imapHost": str(extra.get("imapHost") or ""), + "imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)), + "imapUsername": str(extra.get("imapUsername") or ""), + "imapPassword": str(extra.get("imapPassword") or ""), + "imapMailbox": str(extra.get("imapMailbox") or "INBOX"), + "imapUseSsl": bool(extra.get("imapUseSsl", True)), + "smtpHost": str(extra.get("smtpHost") or ""), + "smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)), + "smtpUsername": str(extra.get("smtpUsername") or ""), + "smtpPassword": str(extra.get("smtpPassword") or ""), + "smtpUseTls": bool(extra.get("smtpUseTls", True)), + "smtpUseSsl": bool(extra.get("smtpUseSsl", False)), + "fromAddress": str(extra.get("fromAddress") or ""), + "autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)), + "pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)), + "markSeen": bool(extra.get("markSeen", True)), + "maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)), + "subjectPrefix": str(extra.get("subjectPrefix") or "Re: "), + "allowFrom": self.normalize_allow_from(extra.get("allowFrom", [])), + } + merged = dict(extra) + merged.update( + { + "enabled": enabled, + "appId": external_app_id, + "appSecret": app_secret, + "port": port, + } + ) + return merged + + def get_bot_channels_from_config(self, bot: BotInstance) -> List[Dict[str, Any]]: + config_data = self.read_bot_config(bot.id) + channels_cfg = config_data.get("channels") + if not isinstance(channels_cfg, dict): + channels_cfg = {} + + send_progress, send_tool_hints = self.read_global_delivery_flags(channels_cfg) + rows: List[Dict[str, Any]] = [ + { + "id": "dashboard", + "bot_id": bot.id, + "channel_type": "dashboard", + "external_app_id": f"dashboard-{bot.id}", + "app_secret": "", + "internal_port": 9000, + "is_active": True, + "extra_config": { + "sendProgress": send_progress, + "sendToolHints": send_tool_hints, + }, + "locked": True, + } + ] + + for ctype, cfg in channels_cfg.items(): + if ctype in {"sendProgress", "sendToolHints", "dashboard"}: + continue + if not isinstance(cfg, dict): + continue + rows.append(self.channel_cfg_to_api_dict(bot.id, ctype, cfg)) + return rows + + def normalize_initial_channels(self, bot_id: str, channels: Optional[List[Any]]) -> List[Dict[str, Any]]: + rows: List[Dict[str, Any]] = [] + seen_types: set[str] = set() + for channel in channels or []: + ctype = str(getattr(channel, "channel_type", "") or "").strip().lower() + if not ctype or ctype == "dashboard" or ctype in seen_types: + continue + seen_types.add(ctype) + rows.append( + { + "id": ctype, + "bot_id": bot_id, + "channel_type": ctype, + "external_app_id": str(getattr(channel, "external_app_id", "") or "").strip() or f"{ctype}-{bot_id}", + "app_secret": str(getattr(channel, "app_secret", "") or "").strip(), + "internal_port": max(1, min(int(getattr(channel, "internal_port", 8080) or 8080), 65535)), + "is_active": bool(getattr(channel, "is_active", True)), + "extra_config": self.normalize_channel_extra(getattr(channel, "extra_config", None)), + "locked": False, + } + ) + return rows + + def parse_message_media(self, bot_id: str, media_raw: Optional[str]) -> List[str]: + if not media_raw: + return [] + try: + parsed = json.loads(media_raw) + return self._normalize_media_list(parsed, bot_id) + except Exception: + return [] + + def normalize_env_params(self, raw: Any) -> Dict[str, str]: + if not isinstance(raw, dict): + return {} + rows: Dict[str, str] = {} + for key, value in raw.items(): + normalized_key = str(key or "").strip().upper() + if not normalized_key or not self._ENV_KEY_RE.fullmatch(normalized_key): + continue + rows[normalized_key] = str(value or "").strip() + return rows + + def get_default_system_timezone(self) -> str: + value = str(self._default_bot_system_timezone or "").strip() or "Asia/Shanghai" + try: + ZoneInfo(value) + return value + except Exception: + return "Asia/Shanghai" + + def normalize_system_timezone(self, raw: Any) -> str: + value = str(raw or "").strip() + if not value: + return self.get_default_system_timezone() + try: + ZoneInfo(value) + except Exception as exc: + raise ValueError("Invalid system timezone. Use an IANA timezone such as Asia/Shanghai.") from exc + return value + + def resolve_bot_env_params(self, bot_id: str, raw: Optional[Dict[str, str]] = None) -> Dict[str, str]: + env_params = self.normalize_env_params(raw if isinstance(raw, dict) else self._read_env_store(bot_id)) + try: + env_params["TZ"] = self.normalize_system_timezone(env_params.get("TZ")) + except ValueError: + env_params["TZ"] = self.get_default_system_timezone() + return env_params + + def parse_env_params(self, raw: Any) -> Dict[str, str]: + return self.normalize_env_params(raw) + + @staticmethod + def safe_float(raw: Any, default: float) -> float: + try: + return float(raw) + except Exception: + return default + + @staticmethod + def safe_int(raw: Any, default: int) -> int: + try: + return int(raw) + except Exception: + return default + + def normalize_resource_limits(self, cpu_cores: Any, memory_mb: Any, storage_gb: Any) -> Dict[str, Any]: + cpu = self.safe_float(cpu_cores, 1.0) + memory = self.safe_int(memory_mb, 1024) + storage = self.safe_int(storage_gb, 10) + if cpu < 0: + cpu = 1.0 + if memory < 0: + memory = 1024 + if storage < 0: + storage = 10 + normalized_cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) + normalized_memory = 0 if memory == 0 else min(65536, max(256, memory)) + normalized_storage = 0 if storage == 0 else min(1024, max(1, storage)) + return { + "cpu_cores": normalized_cpu, + "memory_mb": normalized_memory, + "storage_gb": normalized_storage, + } + + def sync_workspace_channels( + self, + session: Session, + bot_id: str, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + bot = session.get(BotInstance, bot_id) + if not bot: + return + snapshot = self._read_bot_runtime_snapshot(bot) + default_target = self.default_provider_target() + bot_data: Dict[str, Any] = { + "name": bot.name, + "node_id": snapshot.get("node_id") or default_target.node_id, + "transport_kind": snapshot.get("transport_kind") or default_target.transport_kind, + "runtime_kind": snapshot.get("runtime_kind") or default_target.runtime_kind, + "core_adapter": snapshot.get("core_adapter") or default_target.core_adapter, + "system_prompt": snapshot.get("system_prompt") or self._default_soul_md, + "soul_md": snapshot.get("soul_md") or self._default_soul_md, + "agents_md": snapshot.get("agents_md") or self._default_agents_md, + "user_md": snapshot.get("user_md") or self._default_user_md, + "tools_md": snapshot.get("tools_md") or self._default_tools_md, + "identity_md": snapshot.get("identity_md") or self._default_identity_md, + "llm_provider": snapshot.get("llm_provider") or "dashscope", + "llm_model": snapshot.get("llm_model") or "", + "api_key": snapshot.get("api_key") or "", + "api_base": snapshot.get("api_base") or "", + "temperature": self.safe_float(snapshot.get("temperature"), 0.2), + "top_p": self.safe_float(snapshot.get("top_p"), 1.0), + "max_tokens": self.safe_int(snapshot.get("max_tokens"), 8192), + "cpu_cores": self.safe_float(snapshot.get("cpu_cores"), 1.0), + "memory_mb": self.safe_int(snapshot.get("memory_mb"), 1024), + "storage_gb": self.safe_int(snapshot.get("storage_gb"), 10), + "send_progress": bool(snapshot.get("send_progress")), + "send_tool_hints": bool(snapshot.get("send_tool_hints")), + } + if isinstance(runtime_overrides, dict): + for key, value in runtime_overrides.items(): + if key in {"api_key", "llm_provider", "llm_model"}: + text = str(value or "").strip() + if not text: + continue + bot_data[key] = text + continue + if key == "api_base": + bot_data[key] = str(value or "").strip() + continue + bot_data[key] = value + resources = self.normalize_resource_limits( + bot_data.get("cpu_cores"), + bot_data.get("memory_mb"), + bot_data.get("storage_gb"), + ) + bot_data["cpu_cores"] = resources["cpu_cores"] + bot_data["memory_mb"] = resources["memory_mb"] + bot_data["storage_gb"] = resources["storage_gb"] + send_progress = bool(bot_data.get("send_progress", False)) + send_tool_hints = bool(bot_data.get("send_tool_hints", False)) + if isinstance(global_delivery_override, dict): + if "sendProgress" in global_delivery_override: + send_progress = bool(global_delivery_override.get("sendProgress")) + if "sendToolHints" in global_delivery_override: + send_tool_hints = bool(global_delivery_override.get("sendToolHints")) + + channels_data = channels_override if channels_override is not None else self.get_bot_channels_from_config(bot) + bot_data["send_progress"] = send_progress + bot_data["send_tool_hints"] = send_tool_hints + normalized_channels: List[Dict[str, Any]] = [] + for row in channels_data: + ctype = str(row.get("channel_type") or "").strip().lower() + if not ctype or ctype == "dashboard": + continue + normalized_channels.append( + { + "channel_type": ctype, + "external_app_id": str(row.get("external_app_id") or ""), + "app_secret": str(row.get("app_secret") or ""), + "internal_port": max(1, min(int(row.get("internal_port") or 8080), 65535)), + "is_active": bool(row.get("is_active", True)), + "extra_config": self.normalize_channel_extra(row.get("extra_config")), + } + ) + self._config_manager.update_workspace( + bot_id=bot_id, + bot_data=bot_data, + channels=normalized_channels, + ) + self.write_bot_resources( + bot_id, + bot_data.get("cpu_cores"), + bot_data.get("memory_mb"), + bot_data.get("storage_gb"), + ) + + def set_bot_provider_target(self, bot_id: str, target: ProviderTarget) -> None: + self.set_provider_target_override(bot_id, target) + + def sync_bot_workspace_via_provider( + self, + session: Session, + bot: BotInstance, + *, + target_override: Optional[ProviderTarget] = None, + channels_override: Optional[List[Dict[str, Any]]] = None, + global_delivery_override: Optional[Dict[str, Any]] = None, + runtime_overrides: Optional[Dict[str, Any]] = None, + ) -> None: + bot_id = str(bot.id or "") + previous_override = self._provider_target_overrides.get(bot_id) + wrote_target = False + try: + if target_override is not None: + self.set_bot_provider_target(bot_id, target_override) + wrote_target = True + self._get_provision_provider(self._app.state, bot).sync_bot_workspace( + session=session, + bot_id=bot_id, + channels_override=channels_override, + global_delivery_override=global_delivery_override, + runtime_overrides=runtime_overrides, + ) + except Exception: + if wrote_target: + if previous_override is not None: + self.set_provider_target_override(bot_id, previous_override) + else: + self.clear_provider_target_override(bot_id) + raise + + def workspace_root(self, bot_id: str) -> str: + return os.path.abspath(os.path.join(self._bots_workspace_root, bot_id, ".nanobot", "workspace")) + + def bot_data_root(self, bot_id: str) -> str: + return os.path.abspath(os.path.join(self._bots_workspace_root, bot_id, ".nanobot")) + + def cron_store_path(self, bot_id: str) -> str: + return os.path.join(self.bot_data_root(bot_id), "cron", "jobs.json") + + def env_store_path(self, bot_id: str) -> str: + return os.path.join(self.bot_data_root(bot_id), "env.json") + + def sessions_root(self, bot_id: str) -> str: + return os.path.join(self.workspace_root(bot_id), "sessions") + + def clear_bot_sessions(self, bot_id: str) -> int: + 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.list_tree( + bot_id=bot_id, + path="sessions", + recursive=True, + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + self._logger, + key=f"sessions-clear-list:{node_id}:{bot_id}", + exc=exc, + message=f"Failed to list edge session files for bot_id={bot_id}", + ) + return 0 + deleted = 0 + for entry in list(payload.get("entries") or []): + if not isinstance(entry, dict): + continue + if str(entry.get("type") or "").strip().lower() != "file": + continue + rel_path = str(entry.get("path") or "").strip().replace("\\", "/") + if not rel_path.lower().startswith("sessions/") or not rel_path.lower().endswith(".jsonl"): + continue + try: + result = client.delete_workspace_path( + bot_id=bot_id, + path=rel_path, + workspace_root=workspace_root, + ) + if bool(result.get("deleted")): + deleted += 1 + except Exception as exc: + log_edge_failure( + self._logger, + key=f"sessions-clear-delete:{node_id}:{bot_id}:{rel_path}", + exc=exc, + message=f"Failed to delete edge session file for bot_id={bot_id}, path={rel_path}", + ) + return deleted + root = self.sessions_root(bot_id) + if not os.path.isdir(root): + return 0 + deleted = 0 + for name in os.listdir(root): + path = os.path.join(root, name) + if not os.path.isfile(path): + continue + if not name.lower().endswith(".jsonl"): + continue + try: + os.remove(path) + deleted += 1 + except Exception: + continue + return deleted + + def clear_bot_dashboard_direct_session(self, bot_id: str) -> Dict[str, Any]: + edge_context = self.resolve_edge_state_context(bot_id) + if edge_context is not None: + client, workspace_root, node_id = edge_context + path = "sessions/dashboard_direct.jsonl" + existed = False + try: + payload = client.list_tree( + bot_id=bot_id, + path="sessions", + recursive=False, + workspace_root=workspace_root, + ) + existed = any( + isinstance(entry, dict) + and str(entry.get("type") or "").strip().lower() == "file" + and str(entry.get("path") or "").strip().replace("\\", "/") == path + for entry in list(payload.get("entries") or []) + ) + except Exception as exc: + log_edge_failure( + self._logger, + key=f"dashboard-session-check:{node_id}:{bot_id}", + exc=exc, + message=f"Failed to inspect edge dashboard session file for bot_id={bot_id}", + ) + try: + client.write_text_file( + bot_id=bot_id, + path=path, + content="", + workspace_root=workspace_root, + ) + except Exception as exc: + log_edge_failure( + self._logger, + key=f"dashboard-session-clear:{node_id}:{bot_id}", + exc=exc, + message=f"Failed to truncate edge dashboard session file for bot_id={bot_id}", + ) + raise + return {"path": path, "existed": existed} + root = self.sessions_root(bot_id) + os.makedirs(root, exist_ok=True) + path = os.path.join(root, "dashboard_direct.jsonl") + existed = os.path.exists(path) + with open(path, "w", encoding="utf-8"): + pass + return {"path": path, "existed": existed} + + def resolve_workspace_path(self, bot_id: str, rel_path: Optional[str] = None) -> tuple[str, str]: + root = self.workspace_root(bot_id) + rel = str(rel_path or "").strip().replace("\\", "/") + target = os.path.abspath(os.path.join(root, rel)) + if os.path.commonpath([root, target]) != root: + raise HTTPException(status_code=400, detail="invalid workspace path") + return root, target + + @staticmethod + def calc_dir_size_bytes(path: str) -> int: + total = 0 + if not os.path.exists(path): + return 0 + for root, _, files in os.walk(path): + for filename in files: + try: + file_path = os.path.join(root, filename) + if os.path.islink(file_path): + continue + total += os.path.getsize(file_path) + except Exception: + continue + return max(0, total) + + @staticmethod + def is_image_attachment_path(path: str) -> bool: + lower = str(path or "").strip().lower() + return lower.endswith(".png") or lower.endswith(".jpg") or lower.endswith(".jpeg") or lower.endswith(".webp") + + @staticmethod + def is_video_attachment_path(path: str) -> bool: + lower = str(path or "").strip().lower() + return ( + lower.endswith(".mp4") + or lower.endswith(".mov") + or lower.endswith(".m4v") + or lower.endswith(".webm") + or lower.endswith(".mkv") + or lower.endswith(".avi") + ) + + def is_visual_attachment_path(self, path: str) -> bool: + return self.is_image_attachment_path(path) or self.is_video_attachment_path(path) + + def ensure_provider_target_supported(self, target: ProviderTarget) -> None: + key = self._resolve_provider_bundle_key(target) + if key is None: + raise HTTPException(status_code=400, detail=f"Execution target is not supported yet: {target.key}") diff --git a/backend/services/bot_lifecycle_service.py b/backend/services/bot_lifecycle_service.py new file mode 100644 index 0000000..cbb85c6 --- /dev/null +++ b/backend/services/bot_lifecycle_service.py @@ -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) diff --git a/backend/services/bot_message_service.py b/backend/services/bot_message_service.py new file mode 100644 index 0000000..6dbb01c --- /dev/null +++ b/backend/services/bot_message_service.py @@ -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, + } diff --git a/backend/services/bot_query_service.py b/backend/services/bot_query_service.py new file mode 100644 index 0000000..314d247 --- /dev/null +++ b/backend/services/bot_query_service.py @@ -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.", + ) diff --git a/backend/services/bot_runtime_snapshot_service.py b/backend/services/bot_runtime_snapshot_service.py new file mode 100644 index 0000000..7ea045e --- /dev/null +++ b/backend/services/bot_runtime_snapshot_service.py @@ -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) diff --git a/backend/services/dashboard_auth_service.py b/backend/services/dashboard_auth_service.py new file mode 100644 index 0000000..b965558 --- /dev/null +++ b/backend/services/dashboard_auth_service.py @@ -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) diff --git a/backend/services/image_service.py b/backend/services/image_service.py new file mode 100644 index 0000000..35b91c2 --- /dev/null +++ b/backend/services/image_service.py @@ -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 diff --git a/backend/services/platform_activity_service.py b/backend/services/platform_activity_service.py new file mode 100644 index 0000000..d495073 --- /dev/null +++ b/backend/services/platform_activity_service.py @@ -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() diff --git a/backend/services/platform_analytics_service.py b/backend/services/platform_analytics_service.py new file mode 100644 index 0000000..bf7fe40 --- /dev/null +++ b/backend/services/platform_analytics_service.py @@ -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() diff --git a/backend/services/platform_common.py b/backend/services/platform_common.py new file mode 100644 index 0000000..bbec59e --- /dev/null +++ b/backend/services/platform_common.py @@ -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) diff --git a/backend/services/platform_overview_service.py b/backend/services/platform_overview_service.py new file mode 100644 index 0000000..62495ff --- /dev/null +++ b/backend/services/platform_overview_service.py @@ -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, + }, + } diff --git a/backend/services/platform_service.py b/backend/services/platform_service.py index 51f0e9f..4e29199 100644 --- a/backend/services/platform_service.py +++ b/backend/services/platform_service.py @@ -1,1199 +1,61 @@ -import json -import logging -import math -import os -import re -import uuid -from datetime import datetime, timedelta -from typing import Any, Callable, Dict, List, Optional, Tuple - -from sqlalchemy import delete as sql_delete, func -from sqlmodel import Session, select - -from clients.edge.errors import log_edge_failure -from core.database import engine -from core.settings import ( - BOTS_WORKSPACE_ROOT, - DEFAULT_CHAT_PULL_PAGE_SIZE, - DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, - DEFAULT_PAGE_SIZE, - DEFAULT_STT_AUDIO_FILTER, - DEFAULT_STT_AUDIO_PREPROCESS, - DEFAULT_STT_DEFAULT_LANGUAGE, - DEFAULT_STT_FORCE_SIMPLIFIED, - DEFAULT_STT_INITIAL_PROMPT, - DEFAULT_STT_MAX_AUDIO_SECONDS, - DEFAULT_UPLOAD_MAX_MB, - DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, - STT_DEVICE, - STT_ENABLED_DEFAULT, - STT_MODEL, +from services.platform_activity_service import ( + list_activity_events, + prune_expired_activity_events, + record_activity_event, ) -from models.bot import BotInstance, NanobotImage -from models.platform import BotActivityEvent, BotRequestUsage, PlatformSetting -from schemas.platform import ( - LoadingPageSettings, - PlatformActivityItem, - PlatformSettingsPayload, - PlatformUsageResponse, - PlatformUsageItem, - PlatformUsageSummary, - SystemSettingItem, - SystemSettingPayload, +from services.platform_analytics_service import build_dashboard_analytics +from services.platform_overview_service import ( + build_node_resource_overview, + build_platform_overview, +) +from services.platform_settings_service import ( + create_or_update_system_setting, + delete_system_setting, + ensure_default_system_settings, + get_allowed_attachment_extensions, + get_chat_pull_page_size, + get_page_size, + get_platform_settings, + get_platform_settings_snapshot, + get_speech_runtime_settings, + get_sys_auth_token_ttl_days, + get_upload_max_mb, + get_workspace_download_extensions, + list_system_settings, + save_platform_settings, +) +from services.platform_usage_service import ( + bind_usage_message, + create_usage_request, + fail_latest_usage, + finalize_usage_from_packet, + list_usage, ) -DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS: tuple[str, ...] = () -DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS = 7 -ACTIVITY_EVENT_RETENTION_SETTING_KEY = "activity_event_retention_days" -ACTIVITY_EVENT_PRUNE_INTERVAL = timedelta(minutes=10) -OPERATIONAL_ACTIVITY_EVENT_TYPES = { - "bot_created", - "bot_deployed", - "bot_started", - "bot_stopped", - "bot_warning", - "bot_enabled", - "bot_disabled", - "bot_deactivated", - "command_submitted", - "command_failed", - "history_cleared", -} -SETTING_KEYS = ( - "page_size", - "chat_pull_page_size", - "command_auto_unlock_seconds", - "upload_max_mb", - "allowed_attachment_extensions", - "workspace_download_extensions", - "speech_enabled", -) -PROTECTED_SETTING_KEYS = set(SETTING_KEYS) | {ACTIVITY_EVENT_RETENTION_SETTING_KEY} -DEPRECATED_SETTING_KEYS = { - "loading_page", - "speech_max_audio_seconds", - "speech_default_language", - "speech_force_simplified", - "speech_audio_preprocess", - "speech_audio_filter", - "speech_initial_prompt", -} -SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { - "page_size": { - "name": "分页大小", - "category": "ui", - "description": "平台各类列表默认每页条数。", - "value_type": "integer", - "value": DEFAULT_PAGE_SIZE, - "is_public": True, - "sort_order": 5, - }, - "chat_pull_page_size": { - "name": "对话懒加载条数", - "category": "chat", - "description": "Bot 对话区向上懒加载时每次读取的消息条数。", - "value_type": "integer", - "value": DEFAULT_CHAT_PULL_PAGE_SIZE, - "is_public": True, - "sort_order": 8, - }, - "command_auto_unlock_seconds": { - "name": "发送按钮自动恢复秒数", - "category": "chat", - "description": "对话发送后按钮保持停止态的最长秒数,超时后自动恢复为可发送状态。", - "value_type": "integer", - "value": DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, - "is_public": True, - "sort_order": 9, - }, - "upload_max_mb": { - "name": "上传大小限制", - "category": "upload", - "description": "单文件上传大小限制,单位 MB。", - "value_type": "integer", - "value": DEFAULT_UPLOAD_MAX_MB, - "is_public": False, - "sort_order": 10, - }, - "allowed_attachment_extensions": { - "name": "允许附件后缀", - "category": "upload", - "description": "允许上传的附件后缀列表,留空表示不限制。", - "value_type": "json", - "value": list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS), - "is_public": False, - "sort_order": 20, - }, - "workspace_download_extensions": { - "name": "工作区下载后缀", - "category": "workspace", - "description": "命中后缀的工作区文件默认走下载模式。", - "value_type": "json", - "value": list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS), - "is_public": False, - "sort_order": 30, - }, - "speech_enabled": { - "name": "语音识别开关", - "category": "speech", - "description": "控制 Bot 语音转写功能是否启用。", - "value_type": "boolean", - "value": STT_ENABLED_DEFAULT, - "is_public": True, - "sort_order": 32, - }, - ACTIVITY_EVENT_RETENTION_SETTING_KEY: { - "name": "活动事件保留天数", - "category": "maintenance", - "description": "bot_activity_event 运维事件的保留天数,超期记录会自动清理。", - "value_type": "integer", - "value": DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS, - "is_public": False, - "sort_order": 34, - }, -} - -_last_activity_event_prune_at: Optional[datetime] = None -logger = logging.getLogger(__name__) - - -def _utcnow() -> datetime: - return datetime.utcnow() - - -def _normalize_activity_event_retention_days(raw: Any) -> int: - try: - value = int(raw) - except Exception: - value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS - return max(1, min(3650, value)) - - -def _normalize_extension(raw: Any) -> str: - text = str(raw or "").strip().lower() - if not text: - return "" - if text.startswith("*."): - text = text[1:] - if not text.startswith("."): - text = f".{text}" - if not re.fullmatch(r"\.[a-z0-9][a-z0-9._+-]{0,31}", text): - return "" - return text - - -def _normalize_extension_list(rows: Any) -> List[str]: - if not isinstance(rows, list): - return [] - normalized: List[str] = [] - for item in rows: - ext = _normalize_extension(item) - if ext and ext not in normalized: - normalized.append(ext) - return normalized - - -def _legacy_env_int(name: str, default: int, min_value: int, max_value: int) -> int: - raw = os.getenv(name) - if raw is None: - return default - try: - value = int(str(raw).strip()) - except Exception: - value = default - return max(min_value, min(max_value, value)) - - -def _legacy_env_bool(name: str, default: bool) -> bool: - raw = os.getenv(name) - if raw is None: - return default - return str(raw).strip().lower() in {"1", "true", "yes", "on"} - - -def _legacy_env_extensions(name: str, default: List[str]) -> List[str]: - raw = os.getenv(name) - if raw is None: - return list(default) - source = re.split(r"[,;\s]+", str(raw)) - normalized: List[str] = [] - for item in source: - ext = _normalize_extension(item) - if ext and ext not in normalized: - normalized.append(ext) - return normalized - - -def _bootstrap_platform_setting_values() -> Dict[str, Any]: - return { - "page_size": _legacy_env_int("PAGE_SIZE", DEFAULT_PAGE_SIZE, 1, 100), - "chat_pull_page_size": _legacy_env_int( - "CHAT_PULL_PAGE_SIZE", - DEFAULT_CHAT_PULL_PAGE_SIZE, - 10, - 500, - ), - "command_auto_unlock_seconds": _legacy_env_int( - "COMMAND_AUTO_UNLOCK_SECONDS", - DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, - 1, - 600, - ), - "upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048), - "allowed_attachment_extensions": _legacy_env_extensions( - "ALLOWED_ATTACHMENT_EXTENSIONS", - list(DEFAULT_ALLOWED_ATTACHMENT_EXTENSIONS), - ), - "workspace_download_extensions": _legacy_env_extensions( - "WORKSPACE_DOWNLOAD_EXTENSIONS", - list(DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS), - ), - "speech_enabled": _legacy_env_bool("STT_ENABLED", STT_ENABLED_DEFAULT), - } - - -def _bot_workspace_root(bot_id: str) -> str: - return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot", "workspace")) - - -def _bot_data_root(bot_id: str) -> str: - return os.path.abspath(os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot")) - - -def _calc_dir_size_bytes(path: str) -> int: - total = 0 - if not os.path.isdir(path): - return 0 - for root, _, files in os.walk(path): - for name in files: - target = os.path.join(root, name) - try: - if os.path.islink(target): - continue - total += int(os.path.getsize(target)) - except OSError: - continue - return total - - -def _workspace_usage_bytes(runtime: Dict[str, Any], bot_id: str) -> int: - usage = dict(runtime.get("usage") or {}) - value = usage.get("workspace_used_bytes") - if value in {None, 0, "0", ""}: - value = usage.get("container_rw_bytes") - try: - normalized = int(value or 0) - except Exception: - normalized = 0 - if normalized > 0: - return normalized - return _calc_dir_size_bytes(_bot_workspace_root(bot_id)) - - -def _read_bot_resources(bot_id: str) -> Dict[str, Any]: - path = os.path.join(_bot_data_root(bot_id), "resources.json") - raw: Dict[str, Any] = {} - if os.path.isfile(path): - try: - with open(path, "r", encoding="utf-8") as f: - loaded = json.load(f) - if isinstance(loaded, dict): - raw = loaded - except Exception: - raw = {} - - def _safe_float(value: Any, default: float) -> float: - try: - return float(value) - except Exception: - return default - - def _safe_int(value: Any, default: int) -> int: - try: - return int(value) - except Exception: - return default - - cpu = _safe_float(raw.get("cpuCores", raw.get("cpu_cores", 1.0)), 1.0) - memory = _safe_int(raw.get("memoryMB", raw.get("memory_mb", 1024)), 1024) - storage = _safe_int(raw.get("storageGB", raw.get("storage_gb", 10)), 10) - cpu = 0.0 if cpu == 0 else min(16.0, max(0.1, cpu)) - memory = 0 if memory == 0 else min(65536, max(256, memory)) - storage = 0 if storage == 0 else min(1024, max(1, storage)) - return { - "cpu_cores": cpu, - "memory_mb": memory, - "storage_gb": storage, - } - - -def estimate_tokens(text: str) -> int: - content = str(text or "").strip() - if not content: - return 0 - pieces = re.findall(r"[\u4e00-\u9fff]|[A-Za-z0-9_]+|[^\s]", content) - total = 0 - for piece in pieces: - if re.fullmatch(r"[\u4e00-\u9fff]", piece): - total += 1 - elif re.fullmatch(r"[A-Za-z0-9_]+", piece): - total += max(1, math.ceil(len(piece) / 4)) - else: - total += 1 - return max(1, total) - - -def default_platform_settings() -> PlatformSettingsPayload: - bootstrap = _bootstrap_platform_setting_values() - return PlatformSettingsPayload( - page_size=int(bootstrap["page_size"]), - chat_pull_page_size=int(bootstrap["chat_pull_page_size"]), - command_auto_unlock_seconds=int(bootstrap["command_auto_unlock_seconds"]), - upload_max_mb=int(bootstrap["upload_max_mb"]), - allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]), - workspace_download_extensions=list(bootstrap["workspace_download_extensions"]), - speech_enabled=bool(bootstrap["speech_enabled"]), - speech_max_audio_seconds=DEFAULT_STT_MAX_AUDIO_SECONDS, - speech_default_language=DEFAULT_STT_DEFAULT_LANGUAGE, - speech_force_simplified=DEFAULT_STT_FORCE_SIMPLIFIED, - speech_audio_preprocess=DEFAULT_STT_AUDIO_PREPROCESS, - speech_audio_filter=DEFAULT_STT_AUDIO_FILTER, - speech_initial_prompt=DEFAULT_STT_INITIAL_PROMPT, - loading_page=LoadingPageSettings(), - ) - - -def _normalize_setting_key(raw: Any) -> str: - text = str(raw or "").strip() - return re.sub(r"[^a-zA-Z0-9_.-]+", "_", text).strip("._-").lower() - - -def _normalize_setting_value(value: Any, value_type: str) -> Any: - normalized_type = str(value_type or "json").strip().lower() or "json" - if normalized_type == "integer": - return int(value or 0) - if normalized_type == "float": - return float(value or 0) - if normalized_type == "boolean": - if isinstance(value, bool): - return value - return str(value or "").strip().lower() in {"1", "true", "yes", "on"} - if normalized_type == "string": - return str(value or "") - if normalized_type == "json": - return value - raise ValueError(f"Unsupported value_type: {normalized_type}") - - -def _read_setting_value(row: PlatformSetting) -> Any: - try: - value = json.loads(row.value_json or "null") - except Exception: - value = None - return _normalize_setting_value(value, row.value_type) - - -def _setting_item_from_row(row: PlatformSetting) -> Dict[str, Any]: - return SystemSettingItem( - key=row.key, - name=row.name, - category=row.category, - description=row.description, - value_type=row.value_type, - value=_read_setting_value(row), - is_public=bool(row.is_public), - sort_order=int(row.sort_order or 100), - created_at=row.created_at.isoformat() + "Z", - updated_at=row.updated_at.isoformat() + "Z", - ).model_dump() - - -def _upsert_setting_row( - session: Session, - key: str, - *, - name: str, - category: str, - description: str, - value_type: str, - value: Any, - is_public: bool, - sort_order: int, -) -> PlatformSetting: - normalized_key = _normalize_setting_key(key) - if not normalized_key: - raise ValueError("Setting key is required") - normalized_type = str(value_type or "json").strip().lower() or "json" - normalized_value = _normalize_setting_value(value, normalized_type) - now = _utcnow() - row = session.get(PlatformSetting, normalized_key) - if row is None: - row = PlatformSetting( - key=normalized_key, - name=str(name or normalized_key), - category=str(category or "general"), - description=str(description or ""), - value_type=normalized_type, - value_json=json.dumps(normalized_value, ensure_ascii=False), - is_public=bool(is_public), - sort_order=int(sort_order or 100), - created_at=now, - updated_at=now, - ) - else: - row.name = str(name or row.name or normalized_key) - row.category = str(category or row.category or "general") - row.description = str(description or row.description or "") - row.value_type = normalized_type - row.value_json = json.dumps(normalized_value, ensure_ascii=False) - row.is_public = bool(is_public) - row.sort_order = int(sort_order or row.sort_order or 100) - row.updated_at = now - session.add(row) - return row - - -def ensure_default_system_settings(session: Session) -> None: - bootstrap_values = _bootstrap_platform_setting_values() - legacy_row = session.get(PlatformSetting, "global") - if legacy_row is not None: - try: - legacy_data = json.loads(legacy_row.value_json or "{}") - except Exception: - legacy_data = {} - if isinstance(legacy_data, dict): - for key in SETTING_KEYS: - meta = SYSTEM_SETTING_DEFINITIONS[key] - _upsert_setting_row( - session, - key, - name=str(meta["name"]), - category=str(meta["category"]), - description=str(meta["description"]), - value_type=str(meta["value_type"]), - value=legacy_data.get(key, bootstrap_values.get(key, meta["value"])), - is_public=bool(meta["is_public"]), - sort_order=int(meta["sort_order"]), - ) - session.delete(legacy_row) - session.commit() - - dirty = False - for key in DEPRECATED_SETTING_KEYS: - legacy_row = session.get(PlatformSetting, key) - if legacy_row is not None: - session.delete(legacy_row) - dirty = True - - for key, meta in SYSTEM_SETTING_DEFINITIONS.items(): - row = session.get(PlatformSetting, key) - if row is None: - _upsert_setting_row( - session, - key, - name=str(meta["name"]), - category=str(meta["category"]), - description=str(meta["description"]), - value_type=str(meta["value_type"]), - value=bootstrap_values.get(key, meta["value"]), - is_public=bool(meta["is_public"]), - sort_order=int(meta["sort_order"]), - ) - dirty = True - continue - changed = False - for field in ("name", "category", "description", "value_type"): - value = str(meta[field]) - if not getattr(row, field): - setattr(row, field, value) - changed = True - if getattr(row, "sort_order", None) is None: - row.sort_order = int(meta["sort_order"]) - changed = True - if getattr(row, "is_public", None) is None: - row.is_public = bool(meta["is_public"]) - changed = True - if changed: - row.updated_at = _utcnow() - session.add(row) - dirty = True - if dirty: - session.commit() - - -def list_system_settings(session: Session, search: str = "") -> List[Dict[str, Any]]: - ensure_default_system_settings(session) - stmt = select(PlatformSetting).order_by(PlatformSetting.sort_order.asc(), PlatformSetting.key.asc()) - rows = session.exec(stmt).all() - keyword = str(search or "").strip().lower() - items = [_setting_item_from_row(row) for row in rows] - if not keyword: - return items - return [ - item - for item in items - if keyword in str(item["key"]).lower() - or keyword in str(item["name"]).lower() - or keyword in str(item["category"]).lower() - or keyword in str(item["description"]).lower() - ] - - -def create_or_update_system_setting(session: Session, payload: SystemSettingPayload) -> Dict[str, Any]: - ensure_default_system_settings(session) - normalized_key = _normalize_setting_key(payload.key) - definition = SYSTEM_SETTING_DEFINITIONS.get(normalized_key, {}) - row = _upsert_setting_row( - session, - payload.key, - name=payload.name or str(definition.get("name") or payload.key), - category=payload.category or str(definition.get("category") or "general"), - description=payload.description or str(definition.get("description") or ""), - value_type=payload.value_type or str(definition.get("value_type") or "json"), - value=payload.value if payload.value is not None else definition.get("value"), - is_public=payload.is_public, - sort_order=payload.sort_order or int(definition.get("sort_order") or 100), - ) - if normalized_key == ACTIVITY_EVENT_RETENTION_SETTING_KEY: - prune_expired_activity_events(session, force=True) - session.commit() - session.refresh(row) - return _setting_item_from_row(row) - - -def delete_system_setting(session: Session, key: str) -> None: - normalized_key = _normalize_setting_key(key) - if normalized_key in PROTECTED_SETTING_KEYS: - raise ValueError("Core platform settings cannot be deleted") - row = session.get(PlatformSetting, normalized_key) - if row is None: - raise ValueError("Setting not found") - session.delete(row) - session.commit() - - -def get_platform_settings(session: Session) -> PlatformSettingsPayload: - defaults = default_platform_settings() - ensure_default_system_settings(session) - rows = session.exec(select(PlatformSetting).where(PlatformSetting.key.in_(SETTING_KEYS))).all() - data: Dict[str, Any] = {row.key: _read_setting_value(row) for row in rows} - - merged = defaults.model_dump() - merged["page_size"] = max(1, min(100, int(data.get("page_size") or merged["page_size"]))) - merged["chat_pull_page_size"] = max(10, min(500, int(data.get("chat_pull_page_size") or merged["chat_pull_page_size"]))) - merged["command_auto_unlock_seconds"] = max( - 1, - min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])) - ) - merged["upload_max_mb"] = int(data.get("upload_max_mb") or merged["upload_max_mb"]) - merged["allowed_attachment_extensions"] = _normalize_extension_list( - data.get("allowed_attachment_extensions", merged["allowed_attachment_extensions"]) - ) - merged["workspace_download_extensions"] = _normalize_extension_list( - data.get("workspace_download_extensions", merged["workspace_download_extensions"]) - ) - merged["speech_enabled"] = bool(data.get("speech_enabled", merged["speech_enabled"])) - loading_page = data.get("loading_page") - if isinstance(loading_page, dict): - current = dict(merged["loading_page"]) - for key in ("title", "subtitle", "description"): - value = str(loading_page.get(key) or "").strip() - if value: - current[key] = value - merged["loading_page"] = current - return PlatformSettingsPayload.model_validate(merged) - - -def save_platform_settings(session: Session, payload: PlatformSettingsPayload) -> PlatformSettingsPayload: - normalized = PlatformSettingsPayload( - page_size=max(1, min(100, int(payload.page_size))), - chat_pull_page_size=max(10, min(500, int(payload.chat_pull_page_size))), - command_auto_unlock_seconds=max(1, min(600, int(payload.command_auto_unlock_seconds))), - upload_max_mb=payload.upload_max_mb, - allowed_attachment_extensions=_normalize_extension_list(payload.allowed_attachment_extensions), - workspace_download_extensions=_normalize_extension_list(payload.workspace_download_extensions), - speech_enabled=bool(payload.speech_enabled), - loading_page=LoadingPageSettings.model_validate(payload.loading_page.model_dump()), - ) - payload_by_key = normalized.model_dump() - for key in SETTING_KEYS: - definition = SYSTEM_SETTING_DEFINITIONS[key] - _upsert_setting_row( - session, - key, - name=str(definition["name"]), - category=str(definition["category"]), - description=str(definition["description"]), - value_type=str(definition["value_type"]), - value=payload_by_key[key], - is_public=bool(definition["is_public"]), - sort_order=int(definition["sort_order"]), - ) - session.commit() - return normalized - - -def get_platform_settings_snapshot() -> PlatformSettingsPayload: - with Session(engine) as session: - return get_platform_settings(session) - - -def get_upload_max_mb() -> int: - return get_platform_settings_snapshot().upload_max_mb - - -def get_allowed_attachment_extensions() -> List[str]: - return get_platform_settings_snapshot().allowed_attachment_extensions - - -def get_workspace_download_extensions() -> List[str]: - return get_platform_settings_snapshot().workspace_download_extensions - - -def get_page_size() -> int: - return get_platform_settings_snapshot().page_size - - -def get_chat_pull_page_size() -> int: - return get_platform_settings_snapshot().chat_pull_page_size - - -def get_speech_runtime_settings() -> Dict[str, Any]: - settings = get_platform_settings_snapshot() - return { - "enabled": bool(settings.speech_enabled), - "max_audio_seconds": int(DEFAULT_STT_MAX_AUDIO_SECONDS), - "default_language": str(DEFAULT_STT_DEFAULT_LANGUAGE or "zh").strip().lower() or "zh", - "force_simplified": bool(DEFAULT_STT_FORCE_SIMPLIFIED), - "audio_preprocess": bool(DEFAULT_STT_AUDIO_PREPROCESS), - "audio_filter": str(DEFAULT_STT_AUDIO_FILTER or "").strip(), - "initial_prompt": str(DEFAULT_STT_INITIAL_PROMPT or "").strip(), - "model": STT_MODEL, - "device": STT_DEVICE, - } - - -def get_activity_event_retention_days(session: Session) -> int: - row = session.get(PlatformSetting, ACTIVITY_EVENT_RETENTION_SETTING_KEY) - if row is None: - return DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS - try: - value = _read_setting_value(row) - except Exception: - value = DEFAULT_ACTIVITY_EVENT_RETENTION_DAYS - return _normalize_activity_event_retention_days(value) - - -def create_usage_request( - session: Session, - bot_id: str, - command: str, - attachments: Optional[List[str]] = None, - channel: str = "dashboard", - metadata: Optional[Dict[str, Any]] = None, - provider: Optional[str] = None, - model: Optional[str] = None, -) -> str: - request_id = uuid.uuid4().hex - rows = [str(item).strip() for item in (attachments or []) if str(item).strip()] - input_tokens = estimate_tokens(command) - usage = BotRequestUsage( - bot_id=bot_id, - request_id=request_id, - channel=channel, - status="PENDING", - provider=(str(provider or "").strip() or None), - model=(str(model or "").strip() or None), - token_source="estimated", - input_tokens=input_tokens, - output_tokens=0, - total_tokens=input_tokens, - input_text_preview=str(command or "")[:400], - attachments_json=json.dumps(rows, ensure_ascii=False) if rows else None, - metadata_json=json.dumps(metadata or {}, ensure_ascii=False), - started_at=_utcnow(), - created_at=_utcnow(), - updated_at=_utcnow(), - ) - session.add(usage) - session.flush() - return request_id - - -def bind_usage_message( - session: Session, - bot_id: str, - request_id: str, - message_id: Optional[int], -) -> Optional[BotRequestUsage]: - if not request_id or not message_id: - return None - usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) - if not usage_row: - return None - usage_row.message_id = int(message_id) - usage_row.updated_at = _utcnow() - session.add(usage_row) - return usage_row - - -def _find_latest_pending_usage(session: Session, bot_id: str) -> Optional[BotRequestUsage]: - stmt = ( - select(BotRequestUsage) - .where(BotRequestUsage.bot_id == bot_id) - .where(BotRequestUsage.status == "PENDING") - .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) - .limit(1) - ) - return session.exec(stmt).first() - - -def _find_pending_usage_by_request_id(session: Session, bot_id: str, request_id: str) -> Optional[BotRequestUsage]: - if not request_id: - return None - stmt = ( - select(BotRequestUsage) - .where(BotRequestUsage.bot_id == bot_id) - .where(BotRequestUsage.request_id == request_id) - .where(BotRequestUsage.status == "PENDING") - .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) - .limit(1) - ) - return session.exec(stmt).first() - - -def finalize_usage_from_packet(session: Session, bot_id: str, packet: Dict[str, Any]) -> Optional[BotRequestUsage]: - request_id = str(packet.get("request_id") or "").strip() - usage_row = _find_pending_usage_by_request_id(session, bot_id, request_id) or _find_latest_pending_usage(session, bot_id) - if not usage_row: - return None - - raw_usage = packet.get("usage") - input_tokens: Optional[int] = None - output_tokens: Optional[int] = None - source = "estimated" - if isinstance(raw_usage, dict): - for key in ("input_tokens", "prompt_tokens", "promptTokens"): - if raw_usage.get(key) is not None: - try: - input_tokens = int(raw_usage.get(key) or 0) - except Exception: - input_tokens = None - break - for key in ("output_tokens", "completion_tokens", "completionTokens"): - if raw_usage.get(key) is not None: - try: - output_tokens = int(raw_usage.get(key) or 0) - except Exception: - output_tokens = None - break - if input_tokens is not None or output_tokens is not None: - source = "exact" - - text = str(packet.get("text") or packet.get("content") or "").strip() - provider = str(packet.get("provider") or "").strip() - model = str(packet.get("model") or "").strip() - message_id = packet.get("message_id") - if input_tokens is None: - input_tokens = usage_row.input_tokens - if output_tokens is None: - output_tokens = estimate_tokens(text) - if source == "exact": - source = "mixed" - - if provider: - usage_row.provider = provider[:120] - if model: - usage_row.model = model[:255] - if message_id is not None: - try: - usage_row.message_id = int(message_id) - except Exception: - pass - usage_row.output_tokens = max(0, int(output_tokens or 0)) - usage_row.input_tokens = max(0, int(input_tokens or 0)) - usage_row.total_tokens = usage_row.input_tokens + usage_row.output_tokens - usage_row.output_text_preview = text[:400] if text else usage_row.output_text_preview - usage_row.status = "COMPLETED" - usage_row.token_source = source - usage_row.completed_at = _utcnow() - usage_row.updated_at = _utcnow() - session.add(usage_row) - return usage_row - - -def fail_latest_usage(session: Session, bot_id: str, detail: str) -> Optional[BotRequestUsage]: - usage_row = _find_latest_pending_usage(session, bot_id) - if not usage_row: - return None - usage_row.status = "ERROR" - usage_row.error_text = str(detail or "")[:500] - usage_row.completed_at = _utcnow() - usage_row.updated_at = _utcnow() - session.add(usage_row) - return usage_row - - -def prune_expired_activity_events(session: Session, force: bool = False) -> int: - global _last_activity_event_prune_at - - now = _utcnow() - if not force and _last_activity_event_prune_at and now - _last_activity_event_prune_at < ACTIVITY_EVENT_PRUNE_INTERVAL: - return 0 - - retention_days = get_activity_event_retention_days(session) - cutoff = now - timedelta(days=retention_days) - result = session.exec( - sql_delete(BotActivityEvent).where(BotActivityEvent.created_at < cutoff) - ) - _last_activity_event_prune_at = now - return int(getattr(result, "rowcount", 0) or 0) - - -def record_activity_event( - session: Session, - bot_id: str, - event_type: str, - request_id: Optional[str] = None, - channel: str = "dashboard", - detail: Optional[str] = None, - metadata: Optional[Dict[str, Any]] = None, -) -> None: - normalized_event_type = str(event_type or "unknown").strip().lower() or "unknown" - if normalized_event_type not in OPERATIONAL_ACTIVITY_EVENT_TYPES: - return - prune_expired_activity_events(session, force=False) - row = BotActivityEvent( - bot_id=bot_id, - request_id=request_id, - event_type=normalized_event_type, - channel=str(channel or "dashboard").strip().lower() or "dashboard", - detail=(str(detail or "").strip() or None), - metadata_json=json.dumps(metadata or {}, ensure_ascii=False) if metadata else None, - created_at=_utcnow(), - ) - session.add(row) - - -def list_usage( - session: Session, - bot_id: Optional[str] = None, - limit: int = 100, - offset: int = 0, -) -> Dict[str, Any]: - safe_limit = max(1, min(int(limit), 500)) - safe_offset = max(0, int(offset or 0)) - stmt = ( - select(BotRequestUsage) - .order_by(BotRequestUsage.started_at.desc(), BotRequestUsage.id.desc()) - .offset(safe_offset) - .limit(safe_limit) - ) - summary_stmt = select( - func.count(BotRequestUsage.id), - func.coalesce(func.sum(BotRequestUsage.input_tokens), 0), - func.coalesce(func.sum(BotRequestUsage.output_tokens), 0), - func.coalesce(func.sum(BotRequestUsage.total_tokens), 0), - ) - total_stmt = select(func.count(BotRequestUsage.id)) - if bot_id: - stmt = stmt.where(BotRequestUsage.bot_id == bot_id) - summary_stmt = summary_stmt.where(BotRequestUsage.bot_id == bot_id) - total_stmt = total_stmt.where(BotRequestUsage.bot_id == bot_id) - else: - since = _utcnow() - timedelta(days=1) - summary_stmt = summary_stmt.where(BotRequestUsage.created_at >= since) - rows = session.exec(stmt).all() - count, input_sum, output_sum, total_sum = session.exec(summary_stmt).one() - total = int(session.exec(total_stmt).one() or 0) - items = [ - PlatformUsageItem( - id=int(row.id or 0), - bot_id=row.bot_id, - message_id=int(row.message_id) if row.message_id is not None else None, - request_id=row.request_id, - channel=row.channel, - status=row.status, - provider=row.provider, - model=row.model, - token_source=row.token_source, - content=row.input_text_preview or row.output_text_preview, - input_tokens=int(row.input_tokens or 0), - output_tokens=int(row.output_tokens or 0), - total_tokens=int(row.total_tokens or 0), - input_text_preview=row.input_text_preview, - output_text_preview=row.output_text_preview, - started_at=row.started_at.isoformat() + "Z", - completed_at=row.completed_at.isoformat() + "Z" if row.completed_at else None, - ).model_dump() - for row in rows - ] - return PlatformUsageResponse( - summary=PlatformUsageSummary( - request_count=int(count or 0), - input_tokens=int(input_sum or 0), - output_tokens=int(output_sum or 0), - total_tokens=int(total_sum or 0), - ), - items=[PlatformUsageItem.model_validate(item) for item in items], - total=total, - limit=safe_limit, - offset=safe_offset, - has_more=safe_offset + len(items) < total, - ).model_dump() - - -def list_activity_events( - session: Session, - bot_id: Optional[str] = None, - limit: int = 100, -) -> List[Dict[str, Any]]: - deleted = prune_expired_activity_events(session, force=False) - if deleted > 0: - session.commit() - safe_limit = max(1, min(int(limit), 500)) - stmt = select(BotActivityEvent).order_by(BotActivityEvent.created_at.desc(), BotActivityEvent.id.desc()).limit(safe_limit) - if bot_id: - stmt = stmt.where(BotActivityEvent.bot_id == bot_id) - rows = session.exec(stmt).all() - items: List[Dict[str, Any]] = [] - for row in rows: - try: - metadata = json.loads(row.metadata_json or "{}") - except Exception: - metadata = {} - items.append( - PlatformActivityItem( - id=int(row.id or 0), - bot_id=row.bot_id, - request_id=row.request_id, - event_type=row.event_type, - channel=row.channel, - detail=row.detail, - metadata=metadata if isinstance(metadata, dict) else {}, - created_at=row.created_at.isoformat() + "Z", - ).model_dump() - ) - return items - - -def build_platform_overview( - session: Session, - read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None, -) -> Dict[str, Any]: - deleted = prune_expired_activity_events(session, force=False) - if deleted > 0: - session.commit() - bots = session.exec(select(BotInstance)).all() - images = session.exec(select(NanobotImage).order_by(NanobotImage.created_at.desc())).all() - settings = get_platform_settings(session) - - running = 0 - stopped = 0 - disabled = 0 - configured_cpu_total = 0.0 - configured_memory_total = 0 - configured_storage_total = 0 - workspace_used_total = 0 - workspace_limit_total = 0 - live_cpu_percent_total = 0.0 - live_memory_used_total = 0 - live_memory_limit_total = 0 - dirty = False - - bot_rows: List[Dict[str, Any]] = [] - for bot in bots: - enabled = bool(getattr(bot, "enabled", True)) - resources = _read_bot_resources(bot.id) - runtime_status = str(bot.docker_status or "STOPPED").upper() - runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status} - if callable(read_runtime): - try: - runtime_status, runtime = read_runtime(bot) - except Exception as exc: - log_edge_failure( - logger, - key=f"platform-overview-runtime:{bot.id}", - exc=exc, - message=f"Failed to read platform runtime snapshot for bot_id={bot.id}", - ) - runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper() - runtime["docker_status"] = runtime_status - if str(bot.docker_status or "").upper() != runtime_status: - bot.docker_status = runtime_status - session.add(bot) - dirty = True - if runtime_status != "RUNNING" and str(bot.current_state or "").upper() not in {"ERROR"}: - next_state = "IDLE" - if str(bot.current_state or "") != next_state: - bot.current_state = next_state - session.add(bot) - dirty = True - workspace_used = _workspace_usage_bytes(runtime, bot.id) - workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 - - configured_cpu_total += float(resources["cpu_cores"] or 0) - configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 - configured_storage_total += workspace_limit - workspace_used_total += workspace_used - workspace_limit_total += workspace_limit - live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0) - live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0) - live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0) - - if not enabled: - disabled += 1 - elif runtime_status == "RUNNING": - running += 1 - else: - stopped += 1 - - bot_rows.append( - { - "id": bot.id, - "name": bot.name, - "enabled": enabled, - "docker_status": runtime_status, - "image_tag": bot.image_tag, - "llm_provider": getattr(bot, "llm_provider", None), - "llm_model": getattr(bot, "llm_model", None), - "current_state": bot.current_state, - "last_action": bot.last_action, - "resources": resources, - "workspace_usage_bytes": workspace_used, - "workspace_limit_bytes": workspace_limit if workspace_limit > 0 else None, - } - ) - - if dirty: - session.commit() - - usage = list_usage(session, limit=20) - events = list_activity_events(session, limit=20) - - return { - "summary": { - "bots": { - "total": len(bots), - "running": running, - "stopped": stopped, - "disabled": disabled, - }, - "images": { - "total": len(images), - "ready": len([row for row in images if row.status == "READY"]), - "abnormal": len([row for row in images if row.status != "READY"]), - }, - "resources": { - "configured_cpu_cores": round(configured_cpu_total, 2), - "configured_memory_bytes": configured_memory_total, - "configured_storage_bytes": configured_storage_total, - "live_cpu_percent": round(live_cpu_percent_total, 2), - "live_memory_used_bytes": live_memory_used_total, - "live_memory_limit_bytes": live_memory_limit_total, - "workspace_used_bytes": workspace_used_total, - "workspace_limit_bytes": workspace_limit_total, - }, - }, - "images": [ - { - "tag": row.tag, - "version": row.version, - "status": row.status, - "source_dir": row.source_dir, - "created_at": row.created_at.isoformat() + "Z", - } - for row in images - ], - "bots": bot_rows, - "settings": settings.model_dump(), - "usage": usage, - "events": events, - } - - -def build_node_resource_overview( - session: Session, - *, - node_id: str, - read_runtime: Optional[Callable[[BotInstance], Tuple[str, Dict[str, Any]]]] = None, -) -> Dict[str, Any]: - normalized_node_id = str(node_id or "").strip().lower() - bots = session.exec(select(BotInstance).where(BotInstance.node_id == normalized_node_id)).all() - - running = 0 - stopped = 0 - disabled = 0 - configured_cpu_total = 0.0 - configured_memory_total = 0 - configured_storage_total = 0 - workspace_used_total = 0 - workspace_limit_total = 0 - live_cpu_percent_total = 0.0 - live_memory_used_total = 0 - live_memory_limit_total = 0 - dirty = False - - for bot in bots: - enabled = bool(getattr(bot, "enabled", True)) - resources = _read_bot_resources(bot.id) - runtime_status = str(bot.docker_status or "STOPPED").upper() - runtime: Dict[str, Any] = {"usage": {}, "limits": {}, "docker_status": runtime_status} - if callable(read_runtime): - try: - runtime_status, runtime = read_runtime(bot) - except Exception as exc: - log_edge_failure( - logger, - key=f"platform-node-runtime:{normalized_node_id}:{bot.id}", - exc=exc, - message=f"Failed to read node runtime snapshot for bot_id={bot.id}", - ) - runtime_status = str(runtime_status or runtime.get("docker_status") or "STOPPED").upper() - runtime["docker_status"] = runtime_status - if str(bot.docker_status or "").upper() != runtime_status: - bot.docker_status = runtime_status - session.add(bot) - dirty = True - - workspace_used = _workspace_usage_bytes(runtime, bot.id) - workspace_limit = int(resources["storage_gb"] or 0) * 1024 * 1024 * 1024 - - configured_cpu_total += float(resources["cpu_cores"] or 0) - configured_memory_total += int(resources["memory_mb"] or 0) * 1024 * 1024 - configured_storage_total += workspace_limit - workspace_used_total += workspace_used - workspace_limit_total += workspace_limit - live_cpu_percent_total += float((runtime.get("usage") or {}).get("cpu_percent") or 0.0) - live_memory_used_total += int((runtime.get("usage") or {}).get("memory_bytes") or 0) - live_memory_limit_total += int((runtime.get("usage") or {}).get("memory_limit_bytes") or 0) - - if not enabled: - disabled += 1 - elif runtime_status == "RUNNING": - running += 1 - else: - stopped += 1 - - if dirty: - session.commit() - - return { - "node_id": normalized_node_id, - "bots": { - "total": len(bots), - "running": running, - "stopped": stopped, - "disabled": disabled, - }, - "resources": { - "configured_cpu_cores": round(configured_cpu_total, 2), - "configured_memory_bytes": configured_memory_total, - "configured_storage_bytes": configured_storage_total, - "live_cpu_percent": round(live_cpu_percent_total, 2), - "live_memory_used_bytes": live_memory_used_total, - "live_memory_limit_bytes": live_memory_limit_total, - "workspace_used_bytes": workspace_used_total, - "workspace_limit_bytes": workspace_limit_total, - }, - } +__all__ = [ + "bind_usage_message", + "build_dashboard_analytics", + "build_node_resource_overview", + "build_platform_overview", + "create_or_update_system_setting", + "create_usage_request", + "delete_system_setting", + "ensure_default_system_settings", + "fail_latest_usage", + "finalize_usage_from_packet", + "get_allowed_attachment_extensions", + "get_chat_pull_page_size", + "get_page_size", + "get_platform_settings", + "get_platform_settings_snapshot", + "get_speech_runtime_settings", + "get_sys_auth_token_ttl_days", + "get_upload_max_mb", + "get_workspace_download_extensions", + "list_activity_events", + "list_system_settings", + "list_usage", + "prune_expired_activity_events", + "record_activity_event", + "save_platform_settings", +] diff --git a/backend/services/platform_settings_service.py b/backend/services/platform_settings_service.py new file mode 100644 index 0000000..d36563f --- /dev/null +++ b/backend/services/platform_settings_service.py @@ -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)) diff --git a/backend/services/platform_settings_support.py b/backend/services/platform_settings_support.py new file mode 100644 index 0000000..a4bb284 --- /dev/null +++ b/backend/services/platform_settings_support.py @@ -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, + } diff --git a/backend/services/platform_usage_service.py b/backend/services/platform_usage_service.py new file mode 100644 index 0000000..ffbb4a6 --- /dev/null +++ b/backend/services/platform_usage_service.py @@ -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() diff --git a/backend/services/provider_test_service.py b/backend/services/provider_test_service.py new file mode 100644 index 0000000..570c5c6 --- /dev/null +++ b/backend/services/provider_test_service.py @@ -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), + } diff --git a/backend/services/runtime_event_service.py b/backend/services/runtime_event_service.py new file mode 100644 index 0000000..3fc4cfc --- /dev/null +++ b/backend/services/runtime_event_service.py @@ -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) diff --git a/backend/services/runtime_service.py b/backend/services/runtime_service.py index 28934f2..183b64f 100644 --- a/backend/services/runtime_service.py +++ b/backend/services/runtime_service.py @@ -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, *, diff --git a/backend/services/skill_service.py b/backend/services/skill_service.py new file mode 100644 index 0000000..c3bad97 --- /dev/null +++ b/backend/services/skill_service.py @@ -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, + ) diff --git a/backend/services/speech_transcription_service.py b/backend/services/speech_transcription_service.py new file mode 100644 index 0000000..696b9df --- /dev/null +++ b/backend/services/speech_transcription_service.py @@ -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 diff --git a/backend/services/sys_auth_service.py b/backend/services/sys_auth_service.py new file mode 100644 index 0000000..0abcc99 --- /dev/null +++ b/backend/services/sys_auth_service.py @@ -0,0 +1,1215 @@ +import hashlib +import hmac +import os +import secrets +import uuid +from datetime import datetime, timedelta, timezone +from typing import Any, Dict, List, Optional + +import bcrypt +import jwt +from sqlalchemy import delete +from sqlmodel import Session, select + +from core.cache import cache +from core.settings import JWT_ALGORITHM, JWT_SECRET +from models.bot import BotInstance +from models.sys_auth import ( + SysMenu, + SysPermission, + SysRole, + SysRoleMenu, + SysRolePermission, + SysUser, + SysUserBot, +) + +DEFAULT_ADMIN_USERNAME = (str(os.getenv("SYS_ADMIN_DEFAULT_USERNAME") or "admin").strip().lower() or "admin") +DEFAULT_ADMIN_PASSWORD = str(os.getenv("SYS_ADMIN_DEFAULT_PASSWORD") or "admin123").strip() or "admin123" +PASSWORD_ITERATIONS = 120_000 +AUTH_TOKEN_CACHE_PREFIX = "sys_auth:token:" +SUPER_ADMIN_ROLE_KEY = "super_admin" +NORMAL_USER_ROLE_KEY = "normal_user" + +ROLE_SEEDS: List[Dict[str, Any]] = [ + { + "role_key": SUPER_ADMIN_ROLE_KEY, + "name": "Super Admin", + "description": "平台超级管理员,拥有全部菜单与权限。", + "sort_order": 10, + }, + { + "role_key": NORMAL_USER_ROLE_KEY, + "name": "Normal User", + "description": "普通用户,仅可访问已绑定的 Bot 和个人相关页面。", + "sort_order": 20, + }, +] + +MENU_SEEDS: List[Dict[str, Any]] = [ + { + "menu_key": "general", + "parent_key": "", + "title": "General", + "title_en": "General", + "menu_type": "group", + "route_path": "", + "icon": "layout-grid", + "permission_key": "", + "sort_order": 10, + }, + { + "menu_key": "general_dashboard", + "parent_key": "general", + "title": "Dashboard", + "title_en": "Dashboard", + "menu_type": "item", + "route_path": "/dashboard", + "icon": "layout-dashboard", + "permission_key": "general.dashboard.view", + "sort_order": 10, + }, + { + "menu_key": "general_chat", + "parent_key": "general", + "title": "Chat", + "title_en": "Chat", + "menu_type": "item", + "route_path": "/chat", + "icon": "message-circle", + "permission_key": "general.chat.view", + "sort_order": 15, + }, + { + "menu_key": "general_edge", + "parent_key": "general", + "title": "Edge 管理", + "title_en": "Edge Management", + "menu_type": "item", + "route_path": "/dashboard/edges", + "icon": "waypoints", + "permission_key": "general.edge.manage", + "sort_order": 20, + }, + { + "menu_key": "admin", + "parent_key": "", + "title": "Admin", + "title_en": "Admin", + "menu_type": "group", + "route_path": "", + "icon": "shield", + "permission_key": "", + "sort_order": 20, + }, + { + "menu_key": "admin_skills", + "parent_key": "admin", + "title": "技能管理", + "title_en": "Skill Management", + "menu_type": "item", + "route_path": "/admin/skills", + "icon": "wrench", + "permission_key": "admin.skills.manage", + "sort_order": 10, + }, + { + "menu_key": "admin_settings", + "parent_key": "admin", + "title": "系统参数", + "title_en": "System Settings", + "menu_type": "item", + "route_path": "/admin/settings", + "icon": "sliders-horizontal", + "permission_key": "admin.settings.manage", + "sort_order": 20, + }, + { + "menu_key": "admin_templates", + "parent_key": "admin", + "title": "模版管理", + "title_en": "Template Management", + "menu_type": "item", + "route_path": "/admin/templates", + "icon": "files", + "permission_key": "admin.templates.manage", + "sort_order": 30, + }, + { + "menu_key": "admin_deploy", + "parent_key": "admin", + "title": "迁移/部署", + "title_en": "Migration / Deploy", + "menu_type": "item", + "route_path": "/admin/deploy", + "icon": "rocket", + "permission_key": "admin.deploy.manage", + "sort_order": 40, + }, + { + "menu_key": "admin_users", + "parent_key": "admin", + "title": "用户管理", + "title_en": "User Management", + "menu_type": "item", + "route_path": "/admin/users", + "icon": "users", + "permission_key": "admin.users.manage", + "sort_order": 50, + }, + { + "menu_key": "admin_roles", + "parent_key": "admin", + "title": "角色管理", + "title_en": "Role Management", + "menu_type": "item", + "route_path": "/admin/roles", + "icon": "badge-check", + "permission_key": "admin.roles.manage", + "sort_order": 60, + }, + { + "menu_key": "profile", + "parent_key": "", + "title": "Profile", + "title_en": "Profile", + "menu_type": "group", + "route_path": "", + "icon": "user-round", + "permission_key": "", + "sort_order": 30, + }, + { + "menu_key": "profile_usage_logs", + "parent_key": "profile", + "title": "使用日志", + "title_en": "Usage Logs", + "menu_type": "item", + "route_path": "/profile/usage-logs", + "icon": "history", + "permission_key": "profile.usage_logs.view", + "sort_order": 10, + }, + { + "menu_key": "profile_api_tokens", + "parent_key": "profile", + "title": "API 令牌", + "title_en": "API Tokens", + "menu_type": "item", + "route_path": "/profile/api-tokens", + "icon": "key-round", + "permission_key": "profile.api_tokens.view", + "sort_order": 20, + }, +] + +PERMISSION_SEEDS: List[Dict[str, Any]] = [ + { + "permission_key": "general.dashboard.view", + "name": "查看仪表板", + "menu_key": "general_dashboard", + "action": "view", + "description": "查看平台首页仪表板。", + "sort_order": 10, + }, + { + "permission_key": "general.edge.manage", + "name": "管理 Edge 节点", + "menu_key": "general_edge", + "action": "manage", + "description": "查看与维护 Edge 节点。", + "sort_order": 20, + }, + { + "permission_key": "general.chat.view", + "name": "访问 Bot Chat", + "menu_key": "general_chat", + "action": "view", + "description": "访问已绑定 Bot 的聊天与运行页。", + "sort_order": 25, + }, + { + "permission_key": "admin.skills.manage", + "name": "管理技能市场", + "menu_key": "admin_skills", + "action": "manage", + "description": "管理技能包、上传归档与元数据。", + "sort_order": 30, + }, + { + "permission_key": "admin.settings.manage", + "name": "管理系统参数", + "menu_key": "admin_settings", + "action": "manage", + "description": "维护系统参数与运行配置。", + "sort_order": 40, + }, + { + "permission_key": "admin.templates.manage", + "name": "管理平台模板", + "menu_key": "admin_templates", + "action": "manage", + "description": "维护平台级模版内容。", + "sort_order": 50, + }, + { + "permission_key": "admin.deploy.manage", + "name": "管理迁移部署", + "menu_key": "admin_deploy", + "action": "manage", + "description": "查看部署概览、迁移与部署入口。", + "sort_order": 60, + }, + { + "permission_key": "admin.users.manage", + "name": "管理系统用户", + "menu_key": "admin_users", + "action": "manage", + "description": "管理后台用户、状态与默认资料。", + "sort_order": 70, + }, + { + "permission_key": "admin.roles.manage", + "name": "管理系统角色", + "menu_key": "admin_roles", + "action": "manage", + "description": "管理角色与菜单、权限分配。", + "sort_order": 80, + }, + { + "permission_key": "profile.usage_logs.view", + "name": "查看使用日志", + "menu_key": "profile_usage_logs", + "action": "view", + "description": "查看个人使用日志页面。", + "sort_order": 90, + }, + { + "permission_key": "profile.api_tokens.view", + "name": "查看 API 令牌", + "menu_key": "profile_api_tokens", + "action": "view", + "description": "查看个人 API 令牌页面。", + "sort_order": 100, + }, +] + +LEGACY_MENU_KEYS = {"admin_permissions", "admin_menus"} +LEGACY_PERMISSION_KEYS = {"admin.permissions.manage", "admin.menus.manage"} + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def _dt_to_iso(value: Optional[datetime]) -> Optional[str]: + if value is None: + return None + return value.isoformat() + "Z" + + +def _new_salt() -> str: + return secrets.token_hex(16) + + +def hash_password(password: str, salt: str) -> str: + _ = salt + return bcrypt.hashpw(str(password or "").encode("utf-8"), bcrypt.gensalt()).decode("utf-8") + + +def _verify_legacy_pbkdf2_password(password: str, salt: str, stored_hash: str) -> bool: + if not salt or not stored_hash: + return False + payload = hashlib.pbkdf2_hmac( + "sha256", + str(password or "").encode("utf-8"), + bytes.fromhex(str(salt or "")), + PASSWORD_ITERATIONS, + ) + return hmac.compare_digest(payload.hex(), str(stored_hash or "")) + + +def verify_password(password: str, salt: str, stored_hash: str) -> bool: + if not stored_hash: + return False + normalized_hash = str(stored_hash or "") + if normalized_hash.startswith("$2a$") or normalized_hash.startswith("$2b$") or normalized_hash.startswith("$2y$"): + try: + return bool(bcrypt.checkpw(str(password or "").encode("utf-8"), normalized_hash.encode("utf-8"))) + except Exception: + return False + return _verify_legacy_pbkdf2_password(password, salt, stored_hash) + + +def hash_token(token: str) -> str: + return hashlib.sha256(str(token or "").encode("utf-8")).hexdigest() + + +def _auth_token_cache_key(jti: str) -> str: + return f"{AUTH_TOKEN_CACHE_PREFIX}{str(jti or '').strip()}" + + +def _session_payload(user: SysUser, *, jti: str, expires_at: datetime) -> Dict[str, Any]: + return { + "sub": str(int(user.id or 0)), + "username": str(user.username or ""), + "jti": jti, + "iat": int(datetime.now(timezone.utc).timestamp()), + "exp": int(expires_at.replace(tzinfo=timezone.utc).timestamp()), + } + + +def _ensure_auth_cache_ready() -> None: + if not cache.enabled or not cache.ping(): + raise RuntimeError("Redis is required for user session storage") + + +def _seed_roles(session: Session) -> Dict[str, SysRole]: + result: Dict[str, SysRole] = {} + for item in ROLE_SEEDS: + row = session.exec(select(SysRole).where(SysRole.role_key == item["role_key"])).first() + if row is None: + row = SysRole(role_key=item["role_key"]) + row.name = item["name"] + row.description = item["description"] + row.is_active = True + row.sort_order = int(item["sort_order"]) + row.updated_at = _utcnow() + session.add(row) + result[item["role_key"]] = row + session.commit() + for key in list(result.keys()): + result[key] = session.exec(select(SysRole).where(SysRole.role_key == key)).first() + return result + + +def _seed_menus(session: Session) -> Dict[str, SysMenu]: + result: Dict[str, SysMenu] = {} + for item in MENU_SEEDS: + row = session.exec(select(SysMenu).where(SysMenu.menu_key == item["menu_key"])).first() + if row is None: + row = SysMenu(menu_key=item["menu_key"]) + row.parent_key = item["parent_key"] + row.title = item["title"] + row.title_en = item["title_en"] + row.menu_type = item["menu_type"] + row.route_path = item["route_path"] + row.icon = item["icon"] + row.permission_key = item["permission_key"] + row.visible = True + row.sort_order = int(item["sort_order"]) + row.updated_at = _utcnow() + session.add(row) + result[item["menu_key"]] = row + session.commit() + for key in list(result.keys()): + result[key] = session.exec(select(SysMenu).where(SysMenu.menu_key == key)).first() + return result + + +def _cleanup_legacy_auth_entries(session: Session) -> None: + seeded_menu_keys = {str(item["menu_key"]) for item in MENU_SEEDS} + seeded_permission_keys = {str(item["permission_key"]) for item in PERMISSION_SEEDS} + + obsolete_permissions = session.exec( + select(SysPermission).where( + (~SysPermission.permission_key.in_(seeded_permission_keys)) | SysPermission.permission_key.in_(LEGACY_PERMISSION_KEYS) + ) + ).all() + permission_ids = [int(item.id) for item in obsolete_permissions if item.id is not None] + if permission_ids: + session.exec(delete(SysRolePermission).where(SysRolePermission.permission_id.in_(permission_ids))) + session.exec(delete(SysPermission).where(SysPermission.id.in_(permission_ids))) + session.commit() + + obsolete_menus = session.exec( + select(SysMenu).where((~SysMenu.menu_key.in_(seeded_menu_keys)) | SysMenu.menu_key.in_(LEGACY_MENU_KEYS)) + ).all() + menu_ids = [int(item.id) for item in obsolete_menus if item.id is not None] + if menu_ids: + session.exec(delete(SysRoleMenu).where(SysRoleMenu.menu_id.in_(menu_ids))) + session.exec(delete(SysMenu).where(SysMenu.id.in_(menu_ids))) + session.commit() + + +def _seed_permissions(session: Session) -> Dict[str, SysPermission]: + result: Dict[str, SysPermission] = {} + for item in PERMISSION_SEEDS: + row = session.exec(select(SysPermission).where(SysPermission.permission_key == item["permission_key"])).first() + if row is None: + row = SysPermission(permission_key=item["permission_key"]) + row.name = item["name"] + row.menu_key = item["menu_key"] + row.action = item["action"] + row.description = item["description"] + row.sort_order = int(item["sort_order"]) + row.updated_at = _utcnow() + session.add(row) + result[item["permission_key"]] = row + session.commit() + for key in list(result.keys()): + result[key] = session.exec(select(SysPermission).where(SysPermission.permission_key == key)).first() + return result + + +def _ensure_role_menu(session: Session, role_id: int, menu_id: int) -> None: + exists = session.exec( + select(SysRoleMenu).where(SysRoleMenu.role_id == role_id, SysRoleMenu.menu_id == menu_id) + ).first() + if exists is None: + session.add(SysRoleMenu(role_id=role_id, menu_id=menu_id)) + + +def _ensure_role_permission(session: Session, role_id: int, permission_id: int) -> None: + exists = session.exec( + select(SysRolePermission).where( + SysRolePermission.role_id == role_id, + SysRolePermission.permission_id == permission_id, + ) + ).first() + if exists is None: + session.add(SysRolePermission(role_id=role_id, permission_id=permission_id)) + + +def seed_sys_auth(session: Session) -> None: + _cleanup_legacy_auth_entries(session) + roles = _seed_roles(session) + menus = _seed_menus(session) + permissions = _seed_permissions(session) + + admin_role = roles[SUPER_ADMIN_ROLE_KEY] + admin_has_menu_bindings = False + admin_has_permission_bindings = False + if admin_role.id is not None: + admin_has_menu_bindings = ( + session.exec(select(SysRoleMenu).where(SysRoleMenu.role_id == admin_role.id)).first() is not None + ) + admin_has_permission_bindings = ( + session.exec(select(SysRolePermission).where(SysRolePermission.role_id == admin_role.id)).first() is not None + ) + + if admin_role.id is not None and not admin_has_menu_bindings: + for menu in menus.values(): + if menu.id is not None: + _ensure_role_menu(session, admin_role.id, menu.id) + + if admin_role.id is not None and not admin_has_permission_bindings: + for permission in permissions.values(): + if permission.id is not None: + _ensure_role_permission(session, admin_role.id, permission.id) + + normal_user_role = roles.get(NORMAL_USER_ROLE_KEY) + if normal_user_role is not None and normal_user_role.id is not None: + normal_user_menu_keys = {"general_chat", "profile_usage_logs", "profile_api_tokens"} + normal_user_permission_keys = { + "general.chat.view", + "profile.usage_logs.view", + "profile.api_tokens.view", + } + for menu_key in normal_user_menu_keys: + menu = menus.get(menu_key) + if menu is not None and menu.id is not None: + _ensure_role_menu(session, int(normal_user_role.id), int(menu.id)) + for permission_key in normal_user_permission_keys: + permission = permissions.get(permission_key) + if permission is not None and permission.id is not None: + _ensure_role_permission(session, int(normal_user_role.id), int(permission.id)) + session.commit() + + user_count = session.exec(select(SysUser)).all() + if len(user_count) == 0: + salt = _new_salt() + session.add( + SysUser( + username=DEFAULT_ADMIN_USERNAME, + display_name="Administrator", + password_salt=salt, + password_hash=hash_password(DEFAULT_ADMIN_PASSWORD, salt), + role_id=admin_role.id, + is_active=True, + created_at=_utcnow(), + updated_at=_utcnow(), + ) + ) + session.commit() + + +def _normalize_bot_ids(bot_ids: List[str] | None) -> List[str]: + normalized: List[str] = [] + for item in list(bot_ids or []): + bot_id = str(item or "").strip() + if bot_id and bot_id not in normalized: + normalized.append(bot_id) + return normalized + + +def _invalidate_user_bot_access_cache(user_id: int) -> None: + normalized_user_id = int(user_id or 0) + if normalized_user_id <= 0: + return + cache.delete(f"bots:list:user:{normalized_user_id}") + + +def _sync_user_bot_bindings(session: Session, *, user_id: int, bot_ids: List[str]) -> None: + session.exec(delete(SysUserBot).where(SysUserBot.user_id == user_id)) + normalized_bot_ids = _normalize_bot_ids(bot_ids) + if normalized_bot_ids: + rows = session.exec(select(BotInstance.id).where(BotInstance.id.in_(normalized_bot_ids))).all() + existing_bot_ids = {str(item or "").strip() for item in rows} + missing = [bot_id for bot_id in normalized_bot_ids if bot_id not in existing_bot_ids] + if missing: + raise ValueError(f"Bots not found: {', '.join(missing[:5])}") + for bot_id in normalized_bot_ids: + session.add(SysUserBot(user_id=user_id, bot_id=bot_id)) + session.commit() + _invalidate_user_bot_access_cache(user_id) + + +def _list_user_bot_ids(session: Session, user_id: int) -> List[str]: + rows = session.exec( + select(SysUserBot.bot_id).where(SysUserBot.user_id == user_id).order_by(SysUserBot.bot_id.asc()) + ).all() + return [str(item or "").strip() for item in rows if str(item or "").strip()] + + +def _list_user_assigned_bots(session: Session, user_id: int) -> List[Dict[str, Any]]: + bound_bot_ids = _list_user_bot_ids(session, user_id) + if not bound_bot_ids: + return [] + bots = session.exec(select(BotInstance).where(BotInstance.id.in_(bound_bot_ids))).all() + bot_map = {str(bot.id or "").strip(): bot for bot in bots if str(bot.id or "").strip()} + items: List[Dict[str, Any]] = [] + for bot_id in bound_bot_ids: + bot = bot_map.get(bot_id) + if bot is None: + continue + items.append( + { + "id": bot_id, + "name": str(bot.name or bot_id), + "enabled": bool(getattr(bot, "enabled", True)), + "node_id": str(getattr(bot, "node_id", "") or ""), + "node_display_name": str(getattr(bot, "node_id", "") or ""), + "docker_status": str(getattr(bot, "docker_status", "STOPPED") or "STOPPED"), + "image_tag": str(getattr(bot, "image_tag", "") or ""), + } + ) + return items + + +def _is_super_admin(role: Optional[SysRole]) -> bool: + return str(getattr(role, "role_key", "") or "") == SUPER_ADMIN_ROLE_KEY + + +def find_user_by_username(session: Session, username: str) -> Optional[SysUser]: + normalized = str(username or "").strip().lower() + if not normalized: + return None + return session.exec(select(SysUser).where(SysUser.username == normalized)).first() + + +def authenticate_user(session: Session, username: str, password: str) -> Optional[SysUser]: + user = find_user_by_username(session, username) + if user is None or not bool(user.is_active): + return None + stored_hash = str(user.password_hash or "") + stored_salt = str(user.password_salt or "") + if not verify_password(password, stored_salt, stored_hash): + return None + if stored_hash and not stored_hash.startswith("$2"): + user.password_hash = hash_password(password, "") + user.password_salt = "" + user.updated_at = _utcnow() + session.add(user) + session.commit() + session.refresh(user) + return user + + +def issue_user_token(session: Session, user: SysUser) -> tuple[str, datetime]: + _ensure_auth_cache_ready() + from services.platform_settings_service import get_sys_auth_token_ttl_days + + ttl_days = get_sys_auth_token_ttl_days(session) + expires_at = _utcnow() + timedelta(days=ttl_days) + jti = uuid.uuid4().hex + token = jwt.encode(_session_payload(user, jti=jti, expires_at=expires_at), JWT_SECRET, algorithm=JWT_ALGORITHM) + cache.set_json( + _auth_token_cache_key(jti), + { + "user_id": int(user.id or 0), + "username": str(user.username or ""), + "token_hash": hash_token(token), + }, + ttl=max(1, ttl_days * 24 * 60 * 60), + ) + user.current_token_hash = None + user.current_token_expires_at = None + user.last_login_at = _utcnow() + user.updated_at = _utcnow() + session.add(user) + session.commit() + session.refresh(user) + return token, expires_at + + +def revoke_user_token(token: str) -> None: + try: + payload = jwt.decode(str(token or "").strip(), JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except Exception: + return + jti = str(payload.get("jti") or "").strip() + if not jti: + return + cache.delete(_auth_token_cache_key(jti)) + + +def resolve_user_by_token(session: Session, token: str) -> Optional[SysUser]: + candidate = str(token or "").strip() + if not candidate: + return None + try: + payload = jwt.decode(candidate, JWT_SECRET, algorithms=[JWT_ALGORITHM]) + except Exception: + return None + user_id = int(payload.get("sub") or 0) + jti = str(payload.get("jti") or "").strip() + if user_id <= 0 or not jti: + return None + cached = cache.get_json(_auth_token_cache_key(jti)) + if not isinstance(cached, dict): + return None + if int(cached.get("user_id") or 0) != user_id: + return None + if str(cached.get("token_hash") or "").strip() != hash_token(candidate): + return None + user = session.get(SysUser, user_id) + if user is None or not bool(user.is_active): + return None + return user + + +def _role_payload(role: Optional[SysRole]) -> Optional[Dict[str, Any]]: + if role is None: + return None + return { + "id": int(role.id or 0), + "role_key": role.role_key, + "name": role.name, + } + + +def _list_role_permissions(session: Session, role_id: Optional[int]) -> List[str]: + if not role_id: + return [] + rows = session.exec( + select(SysPermission) + .join(SysRolePermission, SysRolePermission.permission_id == SysPermission.id) + .where(SysRolePermission.role_id == role_id) + .order_by(SysPermission.sort_order.asc(), SysPermission.permission_key.asc()) + ).all() + return [str(row.permission_key or "").strip() for row in rows if str(row.permission_key or "").strip()] + + +def _list_role_menus(session: Session, role_id: Optional[int]) -> List[SysMenu]: + if not role_id: + return [] + explicit_menus = session.exec( + select(SysMenu) + .join(SysRoleMenu, SysRoleMenu.menu_id == SysMenu.id) + .where(SysRoleMenu.role_id == role_id, SysMenu.visible == True) + .order_by(SysMenu.sort_order.asc(), SysMenu.id.asc()) + ).all() + permission_bound_menus = session.exec( + select(SysMenu) + .join(SysPermission, SysPermission.menu_key == SysMenu.menu_key) + .join(SysRolePermission, SysRolePermission.permission_id == SysPermission.id) + .where(SysRolePermission.role_id == role_id, SysMenu.visible == True) + .order_by(SysMenu.sort_order.asc(), SysMenu.id.asc()) + ).all() + + menu_map: Dict[str, SysMenu] = {} + for row in [*explicit_menus, *permission_bound_menus]: + key = str(row.menu_key or "").strip() + if key: + menu_map[key] = row + + if not menu_map: + return [] + + all_visible_menus = session.exec(select(SysMenu).where(SysMenu.visible == True)).all() + all_menu_map = {str(row.menu_key or "").strip(): row for row in all_visible_menus if str(row.menu_key or "").strip()} + pending_parent_keys = [str(row.parent_key or "").strip() for row in menu_map.values() if str(row.parent_key or "").strip()] + while pending_parent_keys: + parent_key = pending_parent_keys.pop() + if not parent_key or parent_key in menu_map: + continue + parent = all_menu_map.get(parent_key) + if parent is None: + continue + menu_map[parent_key] = parent + next_parent_key = str(parent.parent_key or "").strip() + if next_parent_key and next_parent_key not in menu_map: + pending_parent_keys.append(next_parent_key) + + return sorted(menu_map.values(), key=lambda row: (int(row.sort_order or 100), int(row.id or 0))) + + +def _build_menu_tree(rows: List[SysMenu]) -> List[Dict[str, Any]]: + menu_map: Dict[str, Dict[str, Any]] = {} + roots: List[Dict[str, Any]] = [] + for row in rows: + menu_key = str(row.menu_key or "").strip() + if not menu_key: + continue + menu_map[menu_key] = { + "menu_key": menu_key, + "parent_key": str(row.parent_key or "").strip(), + "title": row.title, + "title_en": row.title_en, + "menu_type": row.menu_type, + "route_path": row.route_path, + "icon": row.icon, + "permission_key": row.permission_key, + "sort_order": int(row.sort_order or 100), + "children": [], + } + for item in sorted(menu_map.values(), key=lambda value: (value["sort_order"], value["menu_key"])): + parent_key = str(item["parent_key"] or "").strip() + if parent_key and parent_key in menu_map: + menu_map[parent_key]["children"].append(item) + else: + roots.append(item) + return roots + + +def _normalize_role_key(value: str) -> str: + return str(value or "").strip().lower().replace(" ", "_") + + +def _normalize_username(value: str) -> str: + return str(value or "").strip().lower() + + +def _sync_role_bindings( + session: Session, + role: SysRole, + *, + menu_keys: List[str], + permission_keys: List[str], +) -> None: + role_id = int(role.id or 0) + if role_id <= 0: + raise ValueError("Role id is required") + + normalized_menu_keys = sorted({str(key or "").strip() for key in menu_keys if str(key or "").strip()}) + normalized_permission_keys = sorted({str(key or "").strip() for key in permission_keys if str(key or "").strip()}) + + session.exec(delete(SysRoleMenu).where(SysRoleMenu.role_id == role_id)) + session.exec(delete(SysRolePermission).where(SysRolePermission.role_id == role_id)) + session.commit() + + if normalized_menu_keys: + menus = session.exec(select(SysMenu).where(SysMenu.menu_key.in_(normalized_menu_keys))).all() + menu_map = {str(row.menu_key or "").strip(): row for row in menus} + for menu_key in normalized_menu_keys: + row = menu_map.get(menu_key) + if row is not None and row.id is not None: + session.add(SysRoleMenu(role_id=role_id, menu_id=int(row.id))) + + if normalized_permission_keys: + permissions = session.exec( + select(SysPermission).where(SysPermission.permission_key.in_(normalized_permission_keys)) + ).all() + permission_map = {str(row.permission_key or "").strip(): row for row in permissions} + for permission_key in normalized_permission_keys: + row = permission_map.get(permission_key) + if row is not None and row.id is not None: + session.add(SysRolePermission(role_id=role_id, permission_id=int(row.id))) + session.commit() + + +def list_sys_users(session: Session) -> List[Dict[str, Any]]: + roles = session.exec(select(SysRole)).all() + role_map = {int(role.id or 0): role for role in roles if role.id is not None} + users = session.exec(select(SysUser).order_by(SysUser.updated_at.desc(), SysUser.id.desc())).all() + binding_rows = session.exec(select(SysUserBot)).all() + bot_ids_map: Dict[int, List[str]] = {} + for row in binding_rows: + user_id = int(row.user_id or 0) + bot_id = str(row.bot_id or "").strip() + if user_id <= 0 or not bot_id: + continue + bot_ids_map.setdefault(user_id, []).append(bot_id) + result: List[Dict[str, Any]] = [] + for user in users: + role = role_map.get(int(user.role_id or 0)) + result.append( + { + "id": int(user.id or 0), + "username": str(user.username or ""), + "display_name": str(user.display_name or user.username or ""), + "is_active": bool(user.is_active), + "last_login_at": _dt_to_iso(user.last_login_at), + "role": _role_payload(role), + "bot_ids": sorted(bot_ids_map.get(int(user.id or 0), [])), + } + ) + return result + + +def create_sys_user( + session: Session, + *, + username: str, + display_name: str, + password: str, + role_id: int, + is_active: bool, + bot_ids: Optional[List[str]] = None, +) -> Dict[str, Any]: + normalized_username = _normalize_username(username) + if not normalized_username: + raise ValueError("Username is required") + if find_user_by_username(session, normalized_username) is not None: + raise ValueError("Username already exists") + if len(str(password or "")) < 6: + raise ValueError("Password must be at least 6 characters") + role = session.get(SysRole, role_id) + if role is None: + raise ValueError("Role not found") + salt = _new_salt() + row = SysUser( + username=normalized_username, + display_name=str(display_name or "").strip() or normalized_username, + password_salt=salt, + password_hash=hash_password(password, salt), + role_id=int(role.id or 0), + is_active=bool(is_active), + created_at=_utcnow(), + updated_at=_utcnow(), + ) + session.add(row) + session.commit() + session.refresh(row) + _sync_user_bot_bindings(session, user_id=int(row.id or 0), bot_ids=list(bot_ids or [])) + return { + "id": int(row.id or 0), + "username": str(row.username or ""), + "display_name": str(row.display_name or row.username or ""), + "is_active": bool(row.is_active), + "last_login_at": _dt_to_iso(row.last_login_at), + "role": _role_payload(role), + "bot_ids": _list_user_bot_ids(session, int(row.id or 0)), + } + + +def update_sys_user( + session: Session, + *, + user_id: int, + display_name: str, + password: str, + role_id: int, + is_active: bool, + bot_ids: Optional[List[str]] = None, + acting_user_id: int = 0, +) -> Dict[str, Any]: + row = session.get(SysUser, user_id) + if row is None: + raise ValueError("User not found") + if acting_user_id > 0 and int(row.id or 0) == acting_user_id and not is_active: + raise ValueError("Current user cannot be disabled") + role = session.get(SysRole, role_id) + if role is None: + raise ValueError("Role not found") + row.display_name = str(display_name or "").strip() or str(row.username or "") + row.role_id = int(role.id or 0) + row.is_active = bool(is_active) + if str(password or "").strip(): + if len(str(password or "")) < 6: + raise ValueError("Password must be at least 6 characters") + row.password_hash = hash_password(password, "") + row.password_salt = "" + row.updated_at = _utcnow() + session.add(row) + session.commit() + _invalidate_user_bot_access_cache(int(row.id or 0)) + _sync_user_bot_bindings(session, user_id=int(row.id or 0), bot_ids=list(bot_ids or [])) + session.refresh(row) + return { + "id": int(row.id or 0), + "username": str(row.username or ""), + "display_name": str(row.display_name or row.username or ""), + "is_active": bool(row.is_active), + "last_login_at": _dt_to_iso(row.last_login_at), + "role": _role_payload(role), + "bot_ids": _list_user_bot_ids(session, int(row.id or 0)), + } + + +def update_current_sys_user_profile( + session: Session, + *, + user_id: int, + display_name: str, + password: str, +) -> SysUser: + row = session.get(SysUser, user_id) + if row is None: + raise ValueError("User not found") + row.display_name = str(display_name or "").strip() or str(row.username or "") + if str(password or "").strip(): + if len(str(password or "")) < 6: + raise ValueError("Password must be at least 6 characters") + row.password_hash = hash_password(password, "") + row.password_salt = "" + row.updated_at = _utcnow() + session.add(row) + session.commit() + session.refresh(row) + return row + + +def delete_sys_user(session: Session, *, user_id: int, acting_user_id: int = 0) -> None: + row = session.get(SysUser, user_id) + if row is None: + return + if acting_user_id > 0 and int(row.id or 0) == acting_user_id: + raise ValueError("Current user cannot be deleted") + session.exec(delete(SysUserBot).where(SysUserBot.user_id == user_id)) + session.commit() + _invalidate_user_bot_access_cache(user_id) + session.delete(row) + session.commit() + + +def list_sys_roles(session: Session) -> List[Dict[str, Any]]: + roles = session.exec(select(SysRole).order_by(SysRole.sort_order.asc(), SysRole.id.asc())).all() + users = session.exec(select(SysUser)).all() + user_count_map: Dict[int, int] = {} + for user in users: + role_id = int(user.role_id or 0) + if role_id > 0: + user_count_map[role_id] = user_count_map.get(role_id, 0) + 1 + result: List[Dict[str, Any]] = [] + for role in roles: + role_id = int(role.id or 0) + result.append( + { + "id": role_id, + "role_key": str(role.role_key or ""), + "name": str(role.name or ""), + "description": str(role.description or ""), + "is_active": bool(role.is_active), + "sort_order": int(role.sort_order or 100), + "user_count": user_count_map.get(role_id, 0), + "menu_keys": [str(key) for key in _list_role_menu_keys(session, role_id)], + "permission_keys": [str(key) for key in _list_role_permissions(session, role_id)], + } + ) + return result + + +def _list_role_menu_keys(session: Session, role_id: Optional[int]) -> List[str]: + rows = _list_role_menus(session, role_id) + return [str(row.menu_key or "").strip() for row in rows if str(row.menu_key or "").strip()] + + +def list_role_grant_bootstrap(session: Session) -> Dict[str, Any]: + menus = session.exec( + select(SysMenu) + .where(SysMenu.visible == True) + .order_by(SysMenu.sort_order.asc(), SysMenu.id.asc()) + ).all() + permissions = session.exec( + select(SysPermission).order_by(SysPermission.sort_order.asc(), SysPermission.permission_key.asc()) + ).all() + menu_rows = [ + { + "menu_key": str(row.menu_key or ""), + "parent_key": str(row.parent_key or ""), + "title": str(row.title or ""), + "title_en": str(row.title_en or ""), + "menu_type": str(row.menu_type or "item"), + "route_path": str(row.route_path or ""), + "icon": str(row.icon or ""), + "sort_order": int(row.sort_order or 100), + "children": [], + } + for row in menus + ] + return { + "menus": _build_menu_tree_from_dict(menu_rows), + "permissions": [ + { + "id": int(row.id or 0), + "permission_key": str(row.permission_key or ""), + "name": str(row.name or ""), + "menu_key": str(row.menu_key or ""), + "action": str(row.action or "view"), + "description": str(row.description or ""), + "sort_order": int(row.sort_order or 100), + } + for row in permissions + ], + } + + +def _build_menu_tree_from_dict(rows: List[Dict[str, Any]]) -> List[Dict[str, Any]]: + menu_map = {str(item["menu_key"]): {**item, "children": []} for item in rows if str(item.get("menu_key") or "").strip()} + roots: List[Dict[str, Any]] = [] + for item in sorted(menu_map.values(), key=lambda value: (int(value.get("sort_order") or 100), str(value.get("menu_key") or ""))): + parent_key = str(item.get("parent_key") or "").strip() + if parent_key and parent_key in menu_map: + menu_map[parent_key]["children"].append(item) + else: + roots.append(item) + return roots + + +def create_sys_role( + session: Session, + *, + role_key: str, + name: str, + description: str, + is_active: bool, + sort_order: int, + menu_keys: List[str], + permission_keys: List[str], +) -> Dict[str, Any]: + normalized_role_key = _normalize_role_key(role_key) + if not normalized_role_key: + raise ValueError("Role key is required") + exists = session.exec(select(SysRole).where(SysRole.role_key == normalized_role_key)).first() + if exists is not None: + raise ValueError("Role key already exists") + row = SysRole( + role_key=normalized_role_key, + name=str(name or "").strip() or normalized_role_key, + description=str(description or "").strip(), + is_active=bool(is_active), + sort_order=int(sort_order or 100), + created_at=_utcnow(), + updated_at=_utcnow(), + ) + session.add(row) + session.commit() + session.refresh(row) + _sync_role_bindings(session, row, menu_keys=menu_keys, permission_keys=permission_keys) + return get_sys_role_detail(session, int(row.id or 0)) + + +def update_sys_role( + session: Session, + *, + role_id: int, + name: str, + description: str, + is_active: bool, + sort_order: int, + menu_keys: List[str], + permission_keys: List[str], +) -> Dict[str, Any]: + row = session.get(SysRole, role_id) + if row is None: + raise ValueError("Role not found") + if str(row.role_key or "") == "super_admin" and not bool(is_active): + raise ValueError("Super Admin cannot be disabled") + row.name = str(name or "").strip() or str(row.role_key or "") + row.description = str(description or "").strip() + row.is_active = bool(is_active) + row.sort_order = int(sort_order or 100) + row.updated_at = _utcnow() + session.add(row) + session.commit() + session.refresh(row) + _sync_role_bindings(session, row, menu_keys=menu_keys, permission_keys=permission_keys) + return get_sys_role_detail(session, role_id) + + +def get_sys_role_detail(session: Session, role_id: int) -> Dict[str, Any]: + role = session.get(SysRole, role_id) + if role is None: + raise ValueError("Role not found") + users = session.exec(select(SysUser).where(SysUser.role_id == role_id)).all() + return { + "id": int(role.id or 0), + "role_key": str(role.role_key or ""), + "name": str(role.name or ""), + "description": str(role.description or ""), + "is_active": bool(role.is_active), + "sort_order": int(role.sort_order or 100), + "user_count": len(users), + "menu_keys": _list_role_menu_keys(session, role_id), + "permission_keys": _list_role_permissions(session, role_id), + } + + +def delete_sys_role(session: Session, *, role_id: int) -> None: + row = session.get(SysRole, role_id) + if row is None: + return + if str(row.role_key or "") == SUPER_ADMIN_ROLE_KEY: + raise ValueError("Super Admin cannot be deleted") + bound_users = session.exec(select(SysUser).where(SysUser.role_id == role_id)).all() + if bound_users: + raise ValueError("Role is still assigned to users") + session.exec(delete(SysRoleMenu).where(SysRoleMenu.role_id == role_id)) + session.exec(delete(SysRolePermission).where(SysRolePermission.role_id == role_id)) + session.commit() + session.delete(row) + session.commit() + + +def build_user_bootstrap(session: Session, user: SysUser, *, token: str = "", expires_at: Optional[datetime] = None) -> Dict[str, Any]: + role = session.get(SysRole, user.role_id) if user.role_id else None + permissions = _list_role_permissions(session, user.role_id) + menus = _build_menu_tree(_list_role_menus(session, user.role_id)) + assigned_bots = [] if _is_super_admin(role) else _list_user_assigned_bots(session, int(user.id or 0)) + home_path = "/dashboard" + for group in menus: + children = list(group.get("children") or []) + if children: + home_path = str(children[0].get("route_path") or "/dashboard") + break + return { + "token": token, + "expires_at": _dt_to_iso(expires_at), + "user": { + "id": int(user.id or 0), + "username": str(user.username or ""), + "display_name": str(user.display_name or user.username or ""), + "role": _role_payload(role), + }, + "menus": menus, + "permissions": permissions, + "home_path": home_path, + "assigned_bots": assigned_bots, + } + + +def list_accessible_bots_for_user(session: Session, user: SysUser) -> List[BotInstance]: + role = session.get(SysRole, user.role_id) if user.role_id else None + if _is_super_admin(role): + return session.exec(select(BotInstance).order_by(BotInstance.updated_at.desc(), BotInstance.id.asc())).all() + bot_ids = _list_user_bot_ids(session, int(user.id or 0)) + if not bot_ids: + return [] + bots = session.exec(select(BotInstance).where(BotInstance.id.in_(bot_ids))).all() + bot_map = {str(bot.id or "").strip(): bot for bot in bots if str(bot.id or "").strip()} + return [bot_map[bot_id] for bot_id in bot_ids if bot_id in bot_map] + + +def user_can_access_bot(session: Session, user: SysUser, bot_id: str) -> bool: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return False + role = session.get(SysRole, user.role_id) if user.role_id else None + if _is_super_admin(role): + return True + row = session.exec( + select(SysUserBot).where(SysUserBot.user_id == int(user.id or 0), SysUserBot.bot_id == normalized_bot_id) + ).first() + return row is not None diff --git a/backend/services/system_service.py b/backend/services/system_service.py new file mode 100644 index 0000000..8017284 --- /dev/null +++ b/backend/services/system_service.py @@ -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, + }, + } diff --git a/backend/services/workspace_service.py b/backend/services/workspace_service.py index e6f17de..8619907 100644 --- a/backend/services/workspace_service.py +++ b/backend/services/workspace_service.py @@ -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) diff --git a/dashboard-edge/app/api/router.py b/dashboard-edge/app/api/router.py index 76fdd44..f03102a 100644 --- a/dashboard-edge/app/api/router.py +++ b/dashboard-edge/app/api/router.py @@ -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, diff --git a/dashboard-edge/app/runtime/docker_manager.py b/dashboard-edge/app/runtime/docker_manager.py index c4fb398..42ead97 100644 --- a/dashboard-edge/app/runtime/docker_manager.py +++ b/dashboard-edge/app/runtime/docker_manager.py @@ -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, diff --git a/dashboard-edge/app/runtime/native_manager.py b/dashboard-edge/app/runtime/native_manager.py index d04fc7a..8412719 100644 --- a/dashboard-edge/app/runtime/native_manager.py +++ b/dashboard-edge/app/runtime/native_manager.py @@ -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, diff --git a/dashboard-edge/app/services/provision_service.py b/dashboard-edge/app/services/provision_service.py index aea20e7..e088bd6 100644 --- a/dashboard-edge/app/services/provision_service.py +++ b/dashboard-edge/app/services/provision_service.py @@ -39,7 +39,6 @@ class EdgeProvisionService: "qwen": "dashscope", "aliyun-qwen": "dashscope", "moonshot": "kimi", - "vllm": "openai", "xunfei": "openai", "iflytek": "openai", "xfyun": "openai", diff --git a/dashboard-edge/app/services/state_store_service.py b/dashboard-edge/app/services/state_store_service.py index 39eda9b..2beb8cb 100644 --- a/dashboard-edge/app/services/state_store_service.py +++ b/dashboard-edge/app/services/state_store_service.py @@ -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() diff --git a/dashboard-edge/app/services/workspace_service.py b/dashboard-edge/app/services/workspace_service.py index 39d7db0..db0db99 100644 --- a/dashboard-edge/app/services/workspace_service.py +++ b/dashboard-edge/app/services/workspace_service.py @@ -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, *, diff --git a/design/architecture.md b/design/architecture.md index 0ac48bf..67ad40d 100644 --- a/design/architecture.md +++ b/design/architecture.md @@ -6,6 +6,7 @@ - 引擎零侵入:不修改 nanobot 源码,仅通过 workspace 与容器管理接入。 - 镜像显式登记:系统不自动构建,不扫描 `engines/`,只使用 Docker 本地镜像 + DB 注册。 - 可观测性优先:通过容器日志流解析状态并推送到 WebSocket。 +- 代码结构治理纳入正式架构约束;后续前后端拆分与目录边界以 `design/code-structure-standards.md` 为准。 ## 2. 核心组件 diff --git a/design/code-structure-standards.md b/design/code-structure-standards.md new file mode 100644 index 0000000..0d2994c --- /dev/null +++ b/design/code-structure-standards.md @@ -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/` + - 领域模块入口 +- `frontend/src/modules//components` + - 纯视图组件、弹层、区块组件 +- `frontend/src/modules//hooks` + - 领域内控制器 hook、状态编排 hook +- `frontend/src/modules//api` + - 仅该领域使用的 API 请求封装 +- `frontend/src/modules//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//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 必须按主题稳定收口 + +后续所有新增功能与重构,均以本文档为准执行。 diff --git a/design/refactor-modularization-roadmap.md b/design/refactor-modularization-roadmap.md index c2c6dbd..e586cb5 100644 --- a/design/refactor-modularization-roadmap.md +++ b/design/refactor-modularization-roadmap.md @@ -2,6 +2,10 @@ 本文档用于指导当前项目的结构性重构,并为后续“支持同机/远端龙虾 + Docker/Native 双运行模式”升级提前抽离边界。 +补充约束:本路线图负责说明“为什么拆、先拆什么”;具体“怎么拆、拆到什么边界”为强制执行项,统一以下文为准: + +- `design/code-structure-standards.md` + 目标不是一次性大改所有代码,而是先把未来 2 个核心问题理顺: - 当前前端/后端过于集中,后续功能迭代成本越来越高 diff --git a/frontend/src/App.css b/frontend/src/App.css index 92c1fe2..38bc012 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1,3179 +1,4 @@ -:root, -.app-shell[data-theme='dark'] { - --bg: #050d1f; - --bg-2: #0b2248; - --panel: #0f203f; - --panel-soft: #142a52; - --line: #3d5685; - --text: #f2f7ff; - --title: #d7e5ff; - --subtitle: #c1d1ee; - --muted: #9ab1db; - --icon: #d9e8ff; - --icon-muted: #b9ccef; - --brand: #5b95ff; - --brand-soft: #284d8f; - --ok: #25c88a; - --warn: #e2b54a; - --err: #eb6a6a; - --shadow: 0 18px 45px rgba(0, 0, 0, 0.45); -} - -.app-shell[data-theme='light'] { - --bg: #edf2fb; - --bg-2: #d3e1ff; - --panel: #ffffff; - --panel-soft: #f3f7ff; - --line: #b7c7e6; - --text: #10244b; - --title: #0c2149; - --subtitle: #294675; - --muted: #456092; - --icon: #16386f; - --icon-muted: #385b91; - --brand: #2f69e2; - --brand-soft: #d7e6ff; - --ok: #11a56f; - --warn: #c68b14; - --err: #d14b4b; - --shadow: 0 10px 30px rgba(45, 77, 143, 0.12); -} - -* { - box-sizing: border-box; -} - -body { - margin: 0; - color: var(--text); - background-color: var(--bg); - background: - radial-gradient(1200px 800px at -12% -20%, var(--bg-2) 0%, transparent 60%), - radial-gradient(900px 600px at 110% 8%, var(--bg-2) 0%, transparent 62%), - linear-gradient(165deg, var(--bg) 0%, color-mix(in oklab, var(--bg) 75%, #000 25%) 100%); - font-family: 'Avenir Next', 'Segoe UI', 'PingFang SC', 'Noto Sans SC', sans-serif; -} - -.app-shell { - min-height: 100vh; - padding: 18px; -} - -.app-shell-compact { - height: 100dvh; - min-height: 100dvh; - overflow: hidden; -} - -.app-frame { - height: calc(100vh - 36px); - display: grid; - grid-template-rows: auto 1fr; - gap: 14px; -} - -.app-frame.app-frame-no-header { - grid-template-rows: 1fr; - gap: 0; -} - -.app-shell-compact .app-frame { - height: calc(100dvh - 36px); - min-height: calc(100dvh - 36px); -} - -.app-header { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 16px; - padding: 14px 16px; - box-shadow: var(--shadow); - backdrop-filter: blur(2px); -} - -.app-header-top { - align-items: flex-start; -} - -.app-header-actions { - display: inline-flex; - align-items: center; - justify-content: flex-end; - gap: 8px; -} - -.app-header-collapsible { - transition: padding 0.2s ease; -} - -.app-header-collapsible.is-collapsed { - padding-top: 8px; - padding-bottom: 8px; - cursor: pointer; -} - -.app-header-collapsible.is-collapsed .app-header-top { - align-items: center; -} - -.app-header-toggle { - flex: 0 0 auto; -} - -.app-title { - display: flex; - align-items: center; - gap: 10px; -} - -.app-title-main { - display: inline-flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.app-title-icon { - width: 22px; - height: 22px; - object-fit: contain; -} - -.app-title svg { - color: var(--icon); - stroke-width: 2.2; -} - -.app-title h1 { - margin: 0; - font-size: 20px; - font-weight: 800; - color: var(--title); -} - -.app-title p { - margin: 2px 0 0; - color: var(--subtitle); - font-size: 12px; -} - -.app-header-toggle-inline { - border: 0; - background: transparent; - color: var(--icon); - display: inline-flex; - align-items: center; - justify-content: center; - width: 20px; - height: 20px; - padding: 0; - cursor: pointer; -} - -.app-header-toggle-inline:hover { - color: var(--brand); -} - -.global-switches { - display: flex; - gap: 8px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.switch-compact { - display: inline-flex; - align-items: center; - gap: 4px; - padding: 4px 5px; - border-radius: 999px; - border: 1px solid var(--line); - background: var(--panel-soft); -} - -.switch-label { - font-size: 12px; - color: var(--muted); - margin-left: 6px; - margin-right: 2px; -} - -.switch-btn { - border: 1px solid transparent; - border-radius: 999px; - min-width: 34px; - height: 30px; - padding: 0 8px; - background: transparent; - color: var(--icon-muted); - display: inline-flex; - align-items: center; - gap: 4px; - cursor: pointer; - font-size: 11px; - font-weight: 700; -} - -.switch-btn svg { - color: currentColor; - stroke-width: 2.2; -} - -.switch-btn-lang { - min-width: 40px; - padding: 0 10px; - gap: 0; - font-size: 12px; -} - -.switch-btn.active { - border-color: color-mix(in oklab, var(--brand) 60%, var(--line) 40%); - background: color-mix(in oklab, var(--brand) 20%, transparent); - color: var(--icon); -} - -.main-stage { - min-height: 0; - height: 100%; -} - -.app-shell-compact .main-stage { - min-height: 0; - height: 100%; - overflow: hidden; -} - -.app-login-shell { - min-height: calc(100vh - 36px); - display: flex; - align-items: center; - justify-content: center; -} - -.app-login-card { - width: min(420px, calc(100vw - 32px)); - background: var(--panel); - border: 1px solid var(--line); - border-radius: 22px; - box-shadow: var(--shadow); - padding: 28px; - display: flex; - flex-direction: column; - align-items: stretch; - gap: 12px; -} - -.app-login-card h1 { - margin: 0; - font-size: 22px; - font-weight: 800; - color: var(--title); -} - -.app-login-card p { - margin: 0; - color: var(--subtitle); - font-size: 14px; - line-height: 1.6; -} - -.app-login-icon { - width: 34px; - height: 34px; - object-fit: contain; -} - -.app-login-form { - display: flex; - flex-direction: column; - gap: 12px; - margin-top: 6px; -} - -.app-login-error { - color: var(--err); - font-size: 13px; - font-weight: 700; -} - -.app-login-submit { - width: 100%; - height: 42px; - justify-content: center; -} - -.panel { - background: var(--panel); - border: 1px solid var(--line); - border-radius: 16px; - padding: 14px; - box-shadow: var(--shadow); -} - -.panel h2 { - margin: 0; - font-size: 19px; - color: var(--title); -} - -.panel-desc { - margin: 4px 0 0; - color: var(--subtitle); - font-size: 13px; - font-weight: 600; -} - -.grid-2 { - display: grid; - grid-template-columns: 1fr 1fr; - gap: 12px; - height: 100%; -} - -.grid-ops { - display: grid; - grid-template-columns: 320px 1fr 360px; - gap: 12px; - height: 100%; -} - -.grid-ops.grid-ops-forced { - grid-template-columns: minmax(0, 1fr) 360px; -} - -.grid-ops.grid-ops-compact { - grid-template-columns: minmax(0, 1fr); - grid-template-rows: minmax(0, 1fr); -} - -.stack { - display: flex; - flex-direction: column; - gap: 10px; -} - -.input, -.select, -.textarea { - width: 100%; - border: 1px solid color-mix(in oklab, var(--line) 70%, var(--text) 8%); - background: var(--panel-soft); - color: var(--text); - border-radius: 10px; - padding: 9px 10px; - font-size: 13px; - outline: none; -} - -.app-shell[data-theme='light'] .input, -.app-shell[data-theme='light'] .select, -.app-shell[data-theme='light'] .textarea { - background: #f8fbff; -} - -.textarea { - resize: vertical; - min-height: 110px; -} - -.md-area { - min-height: 160px; - font-family: 'SF Mono', Menlo, Consolas, monospace; -} - -.md-lite-editor { - display: flex; - flex-direction: column; - gap: 8px; - width: 100%; - min-width: 0; - box-sizing: border-box; -} - -.md-lite-editor.is-full-height { - flex: 1 1 auto; - align-self: stretch; - height: 100%; - min-height: 0; - overflow: hidden; -} - -.md-lite-editor.is-full-height .md-lite-toolbar { - flex: 0 0 auto; -} - -.md-lite-toolbar { - display: flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - padding: 6px 8px; - border: 1px solid color-mix(in oklab, var(--line) 70%, var(--text) 8%); - border-radius: 10px; - background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%); -} - -.md-lite-toolbtn { - border: 1px solid color-mix(in oklab, var(--line) 78%, var(--text) 8%); - background: var(--panel); - color: var(--text); - border-radius: 8px; - padding: 4px 8px; - font-size: 11px; - font-weight: 700; - line-height: 1.2; - cursor: pointer; -} - -.md-lite-toolbtn:hover { - border-color: color-mix(in oklab, var(--brand) 56%, var(--line) 44%); - color: var(--brand); -} - -.md-lite-toolbar-spacer { - flex: 1 1 auto; -} - -.md-lite-toolbar-hint { - font-size: 11px; - color: var(--muted); -} - -.md-lite-textarea { - min-height: 180px; -} - -.md-lite-editor.is-full-height .md-lite-textarea { - flex: 1 1 auto; - min-height: 0; - height: 100%; - max-height: none; - resize: none; -} - -.field-label { - color: var(--subtitle); - font-size: 12px; -} - -.input:focus, -.select:focus, -.textarea:focus { - border-color: var(--brand); -} - -.input:disabled, -.select:disabled, -.textarea:disabled { - background: color-mix(in oklab, var(--panel-soft) 72%, var(--line) 28%); - color: var(--muted); - cursor: not-allowed; -} - -.btn { - border: 1px solid transparent; - border-radius: 10px; - padding: 9px 12px; - font-size: 13px; - font-weight: 700; - cursor: pointer; -} - -.btn-primary { - background: var(--brand); - color: #fff; -} - -.btn-primary svg { - color: #fff; -} - -.btn-primary:hover { - filter: brightness(1.08); -} - -.btn-secondary { - background: color-mix(in oklab, var(--panel-soft) 78%, var(--brand-soft) 22%); - border-color: var(--line); - color: var(--text); -} - -.btn-secondary svg { - color: var(--icon); - stroke-width: 2.1; -} - -.btn-success { - background: color-mix(in oklab, var(--ok) 20%, var(--panel-soft) 80%); - border-color: color-mix(in oklab, var(--ok) 46%, var(--line) 54%); - color: color-mix(in oklab, var(--text) 72%, white 28%); -} - -.btn-success svg { - color: currentColor; - stroke-width: 2.1; -} - -.btn-danger svg { - color: currentColor; - stroke-width: 2.1; -} - -.btn-danger { - background: color-mix(in oklab, var(--err) 18%, var(--panel-soft) 82%); - border-color: color-mix(in oklab, var(--err) 44%, var(--line) 56%); - color: color-mix(in oklab, var(--text) 70%, white 30%); -} - -.row-between { - display: flex; - align-items: center; - justify-content: space-between; - gap: 8px; -} - -.card { - border: 1px solid var(--line); - border-radius: 12px; - background: var(--panel-soft); - padding: 10px; -} - -.app-shell[data-theme='light'] .card { - background: #f7faff; -} - -.selectable { - transition: border-color 0.2s; -} - -.selectable:hover { - border-color: color-mix(in oklab, var(--brand) 50%, var(--line) 50%); -} - -.badge { - display: inline-flex; - align-items: center; - padding: 3px 8px; - border-radius: 999px; - font-size: 11px; - font-weight: 700; - border: 1px solid; -} - -.badge-ok { - color: color-mix(in oklab, var(--ok) 66%, white 34%); - background: color-mix(in oklab, var(--ok) 22%, transparent); - border-color: color-mix(in oklab, var(--ok) 52%, transparent); -} - -.badge-warn { - color: color-mix(in oklab, var(--warn) 60%, white 40%); - background: color-mix(in oklab, var(--warn) 16%, transparent); - border-color: color-mix(in oklab, var(--warn) 42%, transparent); -} - -.badge-err { - color: color-mix(in oklab, var(--err) 60%, white 40%); - background: color-mix(in oklab, var(--err) 16%, transparent); - border-color: color-mix(in oklab, var(--err) 42%, transparent); -} - -.badge-unknown { - color: var(--muted); - background: color-mix(in oklab, var(--line) 46%, transparent); - border-color: var(--line); -} - -.list-scroll { - overflow: auto; -} - -.wizard-image-list { - display: grid; - gap: 10px; -} - -.wizard-image-list .card { - margin: 0; -} - -.table { - width: 100%; - border-collapse: collapse; - font-size: 13px; -} - -.table th, -.table td { - border-bottom: 1px solid var(--line); - text-align: left; - padding: 8px; -} - -.table th { - color: var(--subtitle); - font-size: 12px; - font-weight: 700; -} - -.image-factory-table th, -.image-factory-table td { - padding-top: 11px; - padding-bottom: 11px; - line-height: 1.55; - vertical-align: top; -} - -.image-factory-meta-line { - margin-top: 4px; - color: var(--muted); - font-size: 11px; - line-height: 1.5; -} - -.mono { - font-family: 'SF Mono', Menlo, Consolas, monospace; -} - -.wizard-steps { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 8px; - margin-bottom: 12px; -} - -.wizard-shell { - background: - linear-gradient(160deg, color-mix(in oklab, var(--panel) 88%, var(--brand-soft) 12%) 0%, var(--panel) 100%); - min-height: 760px; -} - -.wizard-head { - padding: 2px 2px 4px; -} - -.wizard-steps-enhanced .wizard-step { - border-radius: 12px; - border-color: color-mix(in oklab, var(--line) 72%, transparent); - background: color-mix(in oklab, var(--panel-soft) 82%, transparent); - font-weight: 700; -} - -.wizard-steps-4 { - grid-template-columns: repeat(4, minmax(0, 1fr)); -} - -.wizard-step { - border: 1px solid var(--line); - border-radius: 10px; - padding: 8px; - font-size: 12px; - color: var(--muted); - background: color-mix(in oklab, var(--panel-soft) 80%, black 20%); -} - -.wizard-step.active { - color: var(--text); - border-color: color-mix(in oklab, var(--brand) 65%, var(--line) 35%); - background: color-mix(in oklab, var(--brand-soft) 56%, var(--panel-soft) 44%); - box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--brand) 45%, transparent); -} - -.summary-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 8px; -} - -.log-view { - background: color-mix(in oklab, var(--panel-soft) 82%, black 18%); - border: 1px solid var(--line); - border-radius: 10px; - padding: 8px; - height: 100%; - overflow: auto; - font-size: 12px; - font-family: 'SF Mono', Menlo, Consolas, monospace; -} - -.log-line { - padding: 5px 0; - border-bottom: 1px solid color-mix(in oklab, var(--line) 66%, transparent); - color: var(--text); -} - -.ops-chat-panel { - min-width: 0; -} - -.ops-header-band { - border: 1px solid var(--line); - border-radius: 12px; - padding: 10px; - background: color-mix(in oklab, var(--panel-soft) 88%, var(--brand-soft) 12%); -} - -.ops-log-view { - min-height: 420px; -} - -.bot-card { - margin-bottom: 8px; - cursor: pointer; -} - -.bot-card.selected { - border-color: var(--brand); - box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--brand) 45%, transparent); -} - -.bot-name { - font-weight: 700; -} - -.bot-id, -.bot-meta { - color: color-mix(in oklab, var(--text) 70%, var(--muted) 30%); - font-size: 12px; - font-weight: 600; -} - -.telemetry-card { - display: grid; - gap: 7px; - font-size: 13px; -} - -.section-mini-title { - margin: 0; - font-size: 13px; - color: var(--subtitle); - font-weight: 700; -} - -.chat-tabs { - display: flex; - gap: 8px; -} - -.chat-view { - min-height: 420px; - max-height: 62vh; - overflow: auto; - border: 1px solid var(--line); - border-radius: 12px; - padding: 10px; - background: var(--panel-soft); -} - -.chat-bubble { - margin-bottom: 8px; - padding: 8px 10px; - border-radius: 10px; - line-height: 1.45; - white-space: pre-wrap; - color: var(--text); - font-size: 14px; - font-weight: 600; -} - -.chat-bubble.assistant { - border: 1px solid color-mix(in oklab, var(--brand) 45%, var(--line) 55%); - background: color-mix(in oklab, var(--brand-soft) 36%, var(--panel-soft) 64%); -} - -.chat-bubble.user { - border: 1px solid color-mix(in oklab, var(--ok) 55%, var(--line) 45%); - background: color-mix(in oklab, var(--ok) 18%, var(--panel-soft) 82%); -} - -.chat-bubble.system { - border: 1px dashed color-mix(in oklab, var(--warn) 50%, var(--line) 50%); - color: var(--text); - background: color-mix(in oklab, var(--warn) 18%, var(--panel-soft) 82%); - font-size: 12px; - font-weight: 700; -} - -.telemetry-strong { - color: var(--text); -} - -.telemetry-strong .mono { - color: var(--text); -} - -.event-list { - display: grid; - gap: 8px; - max-height: 220px; - overflow: auto; -} - -.event-item { - display: grid; - grid-template-columns: 92px 1fr; - gap: 8px; - align-items: center; - padding: 6px 8px; - border: 1px solid var(--line); - border-radius: 8px; - background: color-mix(in oklab, var(--panel-soft) 90%, var(--panel) 10%); -} - -.event-state { - font-size: 11px; - font-weight: 700; -} - -.event-thinking .event-state { - color: #6ea5ff; -} - -.event-tool_call .event-state { - color: #67d3b1; -} - -.event-success .event-state { - color: #53cf95; -} - -.event-error .event-state { - color: #ef6666; -} - -.dialog-status-strip { - display: grid; - grid-template-columns: auto auto 1fr; - gap: 8px; - align-items: center; -} - -.state-chip { - display: inline-flex; - align-items: center; - gap: 6px; - border: 1px solid var(--line); - border-radius: 999px; - padding: 4px 10px; - font-size: 12px; - font-weight: 800; - color: var(--text); -} - -.state-running { - border-color: color-mix(in oklab, var(--ok) 60%, var(--line) 40%); - background: color-mix(in oklab, var(--ok) 22%, transparent); -} - -.state-active { - border-color: color-mix(in oklab, var(--brand) 65%, var(--line) 35%); - background: color-mix(in oklab, var(--brand) 18%, transparent); -} - -.state-last-action { - font-size: 13px; - color: var(--text); - font-weight: 700; -} - -.dialog-midstate-strip { - display: flex; - gap: 8px; - overflow: auto; -} - -.midstate-pill { - min-width: 240px; - border: 1px solid var(--line); - border-radius: 10px; - padding: 6px 8px; - display: grid; - gap: 4px; - color: var(--text); - background: var(--panel-soft); -} - -.midstate-pill .mono { - font-size: 11px; - font-weight: 800; -} - -.midstate-pill.state-thinking .mono { - color: #6ea5ff; -} - -.midstate-pill.state-tool_call .mono { - color: #67d3b1; -} - -.midstate-pill.state-success .mono { - color: #53cf95; -} - -.midstate-pill.state-error .mono { - color: #ef6666; -} - -.chat-meta-row { - display: flex; - align-items: center; - justify-content: space-between; - margin-bottom: 4px; -} - -.chat-role { - font-size: 11px; - font-weight: 800; - color: color-mix(in oklab, var(--text) 85%, var(--muted) 15%); -} - -.chat-time { - font-size: 10px; - color: var(--muted); -} - -.agent-tabs { - display: flex; - gap: 8px; - flex-wrap: wrap; -} - -.agent-tabs-vertical { - display: flex; - flex-direction: column; - gap: 8px; - min-width: 150px; -} - -.wizard-agent-layout { - display: grid; - grid-template-columns: 170px 1fr; - gap: 10px; - min-height: 420px; -} - -.agent-tab { - border: 1px solid var(--line); - background: var(--panel-soft); - color: var(--text); - border-radius: 8px; - padding: 6px 10px; - cursor: pointer; - font-size: 12px; -} - -.agent-tab.active { - border-color: var(--brand); - background: color-mix(in oklab, var(--brand-soft) 54%, var(--panel-soft) 46%); -} - -.modal-mask { - position: fixed; - inset: 0; - background: rgba(2, 9, 24, 0.5); - display: flex; - align-items: center; - justify-content: center; - z-index: 90; -} - -.modal-card { - width: min(680px, 90vw); - border: 1px solid var(--line); - border-radius: 14px; - background: var(--panel); - color: var(--text); - padding: 14px; - display: flex; - flex-direction: column; - gap: 10px; - box-shadow: 0 20px 50px rgba(0, 0, 0, 0.35); -} - -.modal-wide { - width: min(980px, 94vw); -} - -.app-modal-mask { - z-index: 120; -} - -.app-modal-card { - width: min(1280px, 96vw); - max-height: 94vh; -} - -.app-modal-body { - min-height: 0; - max-height: calc(94vh - 92px); - overflow: auto; -} - -.modal-card h3 { - margin: 0; - color: var(--title); - font-size: 22px; - font-weight: 800; -} - -.modal-title-row { - display: grid; - gap: 4px; -} - -.modal-title-row.modal-title-with-close { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 10px; - position: relative; - padding-right: 42px; - min-height: 28px; -} - -.modal-title-main { - min-width: 0; - display: grid; - gap: 4px; -} - -.modal-title-actions { - position: absolute; - right: 0; - top: 0; - display: inline-flex; - align-items: center; - gap: 6px; -} - -.modal-sub { - color: var(--subtitle); - font-size: 12px; - font-weight: 600; -} - -.slider-row { - display: grid; - gap: 6px; -} - -.wizard-step2-grid { - gap: 10px; -} - -.wizard-step2-card { - gap: 8px; - border-radius: 14px; - padding: 12px; - background: color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%); -} - -.wizard-note-card { - border-style: dashed; -} - -.token-input-row { - align-items: start; -} - -.token-number-input { - max-width: 220px; -} - -.slider-row input[type='range'] { - width: 100%; -} - -.wizard-channel-list { - display: grid; - gap: 8px; -} - -.wizard-channel-card { - min-width: 0; - display: grid; - gap: 6px; -} - -.wizard-channel-compact { - padding: 10px; - border-radius: 10px; -} - -.wizard-dashboard-switches { - display: flex; - flex-wrap: wrap; - gap: 14px; - align-items: center; -} - -.wizard-channel-summary { - display: grid; - gap: 8px; -} - -.wizard-icon-btn { - display: inline-flex; - align-items: center; - gap: 6px; -} - -.icon-btn { - width: 34px; - height: 34px; - padding: 0; - border-radius: 9px; - display: inline-flex; - align-items: center; - justify-content: center; -} - -.factory-kpi-grid { - display: grid; - gap: 8px; - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.kpi-card { - border: 1px solid var(--line); - border-radius: 10px; - background: color-mix(in oklab, var(--panel-soft) 88%, transparent); - padding: 9px; -} - -.kpi-label { - font-size: 11px; - color: var(--subtitle); -} - -.kpi-value { - margin-top: 6px; - font-size: 22px; - font-weight: 800; -} - -/* Readability fallback rules (avoid low contrast when color-mix is limited). */ -.app-shell[data-theme='dark'] .panel { - background: #0f203f; - border-color: #3d5685; - color: #f2f7ff; -} - -.app-shell[data-theme='dark'] .card { - background: #142a52; - border-color: #3d5685; -} - -.app-shell[data-theme='dark'] .btn-secondary { - background: #1d3768; - border-color: #44639a; - color: #f2f7ff; -} - -.app-shell[data-theme='dark'] .btn-success { - background: #173f33; - border-color: #3ea486; - color: #d8fff2; -} - -.app-shell[data-theme='dark'] .btn-danger { - background: #3b1f2a; - border-color: #9d4f65; - color: #ffdce4; -} - -.app-shell[data-theme='light'] .panel { - background: #ffffff; - border-color: #b7c7e6; - color: #132a54; -} - -.app-shell[data-theme='light'] .card { - background: #f7faff; - border-color: #b7c7e6; - color: #132a54; -} - -.app-shell[data-theme='light'] .panel-desc, -.app-shell[data-theme='light'] .field-label, -.app-shell[data-theme='light'] .kicker, -.app-shell[data-theme='light'] .sub, -.app-shell[data-theme='light'] .chat-time, -.app-shell[data-theme='light'] .bot-id, -.app-shell[data-theme='light'] .bot-meta { - color: var(--subtitle); -} - -.app-shell[data-theme='light'] .btn-secondary { - background: #e9f1ff; - border-color: #b8cbee; - color: #163265; -} - -.app-shell[data-theme='light'] .btn-success { - background: #e6f8f1; - border-color: #9fd5bf; - color: #0f6b4e; -} - -.app-shell[data-theme='light'] .btn-danger { - background: #fdeeee; - border-color: #e0b0b0; - color: #8d2439; -} - -.app-shell[data-theme='light'] .input, -.app-shell[data-theme='light'] .select, -.app-shell[data-theme='light'] .textarea { - background: #ffffff; - border-color: #bccbe7; - color: #132a54; -} - -.app-shell[data-theme='light'] .md-lite-toolbar { - background: #f5f9ff; - border-color: #bccbe7; -} - -.app-shell[data-theme='light'] .md-lite-toolbtn { - background: #ffffff; - border-color: #c4d2eb; - color: #17305e; -} - -.app-shell[data-theme='light'] .input:disabled, -.app-shell[data-theme='light'] .select:disabled, -.app-shell[data-theme='light'] .textarea:disabled { - background: #edf2fb; - color: #5b6f97; -} - -.app-shell[data-theme='light'] .modal-card { - background: #ffffff; - border-color: #b7c7e6; - color: #132a54; -} - -.app-shell[data-theme='light'] .modal-card h3 { - color: var(--title); -} - -.app-route-subtitle { - font-size: 11px; - color: var(--muted); - letter-spacing: 0.08em; - text-transform: uppercase; -} - -.app-route-crumb { - border: 0; - padding: 0; - background: transparent; - color: var(--muted); - font-size: 11px; - font-weight: 700; - letter-spacing: 0.08em; - text-transform: uppercase; - cursor: pointer; -} - -.app-route-crumb:hover { - color: var(--text); -} - -.platform-grid { - display: grid; - grid-template-columns: 320px minmax(0, 1fr); - gap: 18px; - min-height: 0; -} - -.platform-grid.is-compact { - grid-template-columns: 1fr; -} - -.platform-main { - display: flex; - flex-direction: column; - gap: 18px; - min-width: 0; -} - -.platform-bot-list-panel { - min-height: 0; -} - -.platform-list-actions { - display: flex; - gap: 8px; -} - -.platform-loading-card { - background: linear-gradient(135deg, rgba(55, 162, 255, 0.16), rgba(23, 43, 88, 0.28)); - border: 1px solid rgba(99, 170, 255, 0.22); -} - -.platform-loading-title { - font-size: 18px; - font-weight: 800; - color: var(--title); -} - -.platform-loading-subtitle { - margin-top: 6px; - font-size: 13px; - color: var(--text); -} - -.platform-loading-description { - margin-top: 8px; - font-size: 12px; - color: var(--muted); -} - -.platform-bot-list-scroll { - display: flex; - flex-direction: column; - gap: 10px; -} - -.platform-bot-card { - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px; - border-radius: 16px; - border: 1px solid rgba(255, 255, 255, 0.08); - background: rgba(8, 13, 22, 0.72); - cursor: pointer; - transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; -} - -.platform-bot-card:hover, -.platform-bot-card.is-selected { - transform: translateY(-1px); - border-color: rgba(97, 174, 255, 0.45); - box-shadow: 0 16px 40px rgba(8, 25, 60, 0.18); -} - -.platform-bot-name { - font-size: 15px; - font-weight: 700; - color: var(--title); -} - -.platform-bot-id { - margin-top: 4px; - font-size: 11px; - color: var(--muted); -} - -.platform-bot-meta { - display: flex; - flex-direction: column; - gap: 6px; - font-size: 12px; - color: var(--muted); -} - -.platform-bot-actions { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; -} - -.platform-enable-switch { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 12px; - color: var(--muted); -} - -.platform-bot-actions-main { - display: flex; - gap: 8px; -} - -.platform-summary-grid { - display: grid; - grid-template-columns: repeat(6, minmax(0, 1fr)); - gap: 14px; -} - -.platform-summary-card { - display: flex; - flex-direction: column; - gap: 8px; -} - -.platform-summary-icon { - width: 42px; - height: 42px; - display: inline-flex; - align-items: center; - justify-content: center; - border-radius: 14px; - color: #fff; -} - -.platform-summary-icon.icon-bot { - background: linear-gradient(145deg, #5474ff 0%, #2f59d7 100%); -} - -.platform-summary-icon.icon-image { - background: linear-gradient(145deg, #28b7a1 0%, #17967d 100%); -} - -.platform-summary-icon.icon-token { - background: linear-gradient(145deg, #f0877d 0%, #d95a7d 100%); -} - -.platform-summary-icon.icon-resource { - background: linear-gradient(145deg, #ed7f9a 0%, #f5a65d 46%, #ffe56c 100%); -} - -.platform-resource-card { - grid-column: span 3; - gap: 14px; -} - -.platform-summary-label { - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--muted); -} - -.platform-summary-value { - font-size: 28px; - font-weight: 800; - color: var(--title); -} - -.platform-summary-meta { - font-size: 12px; - color: var(--muted); -} - -.platform-resource-head { - display: flex; - align-items: center; - gap: 12px; -} - -.platform-resource-subtitle { - margin-top: 4px; - font-size: 12px; - color: var(--muted); -} - -.platform-resource-meters { - display: flex; - flex-direction: column; - gap: 12px; -} - -.platform-resource-meter { - display: grid; - grid-template-columns: 28px minmax(0, 1fr) 80px; - align-items: center; - gap: 12px; -} - -.platform-resource-meter-label { - display: flex; - align-items: center; - justify-content: center; - color: var(--title); -} - -.platform-resource-meter-track { - position: relative; - height: 14px; - overflow: hidden; - border-radius: 999px; - background: rgba(225, 232, 245, 0.58); -} - -.platform-resource-meter-fill { - height: 100%; - border-radius: inherit; - background: linear-gradient(90deg, #7f90ff 0%, #6c5ac3 100%); -} - -.platform-resource-meter-fill.is-memory { - background: linear-gradient(90deg, #6f72e0 0%, #5a43ad 100%); -} - -.platform-resource-meter-fill.is-storage { - background: linear-gradient(90deg, #7f90ff 0%, #6b49b9 100%); -} - -.platform-resource-meter-value { - font-size: 18px; - font-weight: 700; - color: var(--title); - text-align: right; -} - -.platform-resource-footnote { - font-size: 12px; - color: var(--muted); -} - -.platform-main-grid { - display: grid; - grid-template-columns: minmax(0, 1.2fr) minmax(320px, 0.8fr); - gap: 18px; -} - -.platform-monitor-grid { - display: grid; - grid-template-columns: minmax(0, 1fr); - gap: 12px; -} - -.platform-monitor-card { - min-height: 132px; -} - -.platform-monitor-title { - display: inline-flex; - align-items: center; - gap: 8px; - font-size: 12px; - font-weight: 700; - color: var(--title); -} - -.platform-monitor-main { - margin-top: 14px; - font-size: 20px; - font-weight: 800; - color: var(--title); -} - -.platform-monitor-meta { - margin-top: 8px; - font-size: 12px; - color: var(--muted); -} - -.platform-selected-bot-card { - min-height: 240px; -} - -.platform-selected-bot-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.platform-selected-bot-headline { - display: flex; - align-items: baseline; - gap: 14px; - flex-wrap: wrap; -} - -.platform-selected-bot-name-row { - display: inline-flex; - align-items: center; - gap: 10px; - min-width: 0; -} - -.platform-selected-bot-actions { - display: inline-flex; - flex-wrap: wrap; - justify-content: flex-end; - gap: 10px; -} - -.platform-more-menu-anchor { - position: relative; -} - -.platform-selected-bot-action-btn { - display: inline-flex; - align-items: center; - gap: 8px; - min-height: 36px; - padding: 0 14px; - border-radius: 999px; -} - -.platform-selected-bot-name { - font-size: 26px; - font-weight: 800; - color: var(--title); -} - -.platform-selected-bot-id { - font-size: 14px; - color: var(--muted); -} - -.platform-selected-bot-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px 14px; - margin-top: 18px; -} - -.platform-selected-bot-info { - display: flex; - flex-direction: column; - gap: 6px; - min-width: 0; - padding: 12px 14px; - border-radius: 14px; - border: 1px solid rgba(97, 174, 255, 0.14); - background: rgba(255, 255, 255, 0.03); -} - -.platform-selected-bot-info-label { - font-size: 12px; - color: var(--muted); -} - -.platform-selected-bot-info-value { - font-size: 14px; - color: var(--title); - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; -} - -.platform-selected-bot-last-row { - margin-top: 18px; - display: flex; - align-items: flex-end; - justify-content: space-between; - gap: 16px; -} - -.platform-selected-bot-last-copy { - min-width: 0; - flex: 1 1 auto; -} - -.platform-selected-bot-last-label { - display: block; - margin-bottom: 6px; - font-size: 12px; - font-weight: 700; - color: var(--title); -} - -.platform-selected-bot-last-preview { - display: block; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - color: var(--muted); -} - -.platform-selected-bot-last-body { - margin-top: 10px; - padding: 14px; - border-radius: 14px; - border: 1px solid rgba(255, 255, 255, 0.06); - background: rgba(255, 255, 255, 0.03); - color: var(--muted); - line-height: 1.6; - white-space: pre-wrap; - word-break: break-word; -} - -.platform-last-action-btn { - flex-shrink: 0; - align-self: flex-end; - margin-bottom: 2px; -} - -.platform-compact-sheet-mask { - align-items: flex-end; - padding: 0; - background: rgba(8, 13, 22, 0.28); - backdrop-filter: blur(10px); - animation: platform-sheet-mask-in 240ms ease-out both; -} - -.platform-compact-sheet-mask.is-closing { - animation: platform-sheet-mask-out 220ms ease-in both; -} - -.platform-compact-sheet-card { - position: relative; - width: 100%; - max-height: min(84vh, 760px); - overflow: hidden; - border-radius: 28px 28px 0 0; - border: 1px solid color-mix(in oklab, var(--line) 78%, #ffffff 22%); - border-bottom: 0; - background: - linear-gradient(180deg, color-mix(in oklab, var(--panel) 94%, #ffffff 6%) 0%, var(--panel) 100%); - box-shadow: - 0 -24px 60px rgba(8, 17, 34, 0.28), - 0 0 0 1px rgba(255, 255, 255, 0.04) inset; - animation: platform-sheet-up 240ms cubic-bezier(0.18, 0.84, 0.26, 1) both; -} - -.platform-compact-sheet-card.is-closing { - animation: platform-sheet-down 220ms ease-in both; -} - -.platform-compact-sheet-handle { - width: 58px; - height: 6px; - border-radius: 999px; - margin: 12px auto 8px; - background: color-mix(in oklab, var(--line) 70%, var(--title) 30%); -} - -.platform-compact-sheet-body { - display: flex; - flex-direction: column; - gap: 0; - max-height: calc(min(84vh, 760px) - 34px); - overflow-y: auto; - padding: 8px 12px 14px; -} - -.platform-compact-sheet-close { - position: absolute; - top: 14px; - right: 14px; - z-index: 2; - background: color-mix(in oklab, var(--panel-soft) 82%, #ffffff 18%); -} - -.platform-compact-overview { - display: flex; - flex-direction: column; - gap: 12px; -} - -.platform-compact-overview-head h2 { - margin: 0; - font-size: 18px; -} - -.platform-selected-bot-card-compact { - border-radius: 22px; - border-color: color-mix(in oklab, var(--line) 72%, #ffffff 28%); - background: color-mix(in oklab, var(--panel-soft) 88%, #ffffff 12%); - box-shadow: inset 0 0 0 1px rgba(255, 255, 255, 0.04); -} - -@keyframes platform-sheet-up { - from { - transform: translateY(48px) scale(0.985); - opacity: 0; - } - to { - transform: translateY(0); - opacity: 1; - } -} - -@keyframes platform-sheet-down { - from { - transform: translateY(0) scale(1); - opacity: 1; - } - to { - transform: translateY(42px) scale(0.988); - opacity: 0; - } -} - -@keyframes platform-sheet-mask-in { - from { - opacity: 0; - } - to { - opacity: 1; - } -} - -@keyframes platform-sheet-mask-out { - from { - opacity: 1; - } - to { - opacity: 0; - } -} - -.platform-image-list, -.platform-activity-list { - display: flex; - flex-direction: column; - gap: 10px; -} - -.platform-image-row, -.platform-activity-row { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; - padding: 12px 14px; - border-radius: 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); -} - -.platform-image-meta, -.platform-activity-detail, -.platform-activity-type { - font-size: 12px; - color: var(--muted); -} - -.platform-activity-row strong { - margin-right: 8px; -} - -.platform-activity-empty { - padding: 12px 14px; - border-radius: 14px; - border: 1px dashed rgba(97, 174, 255, 0.18); - color: var(--muted); - font-size: 12px; -} - -.platform-entry-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; -} - -.platform-entry-card { - display: flex; - flex-direction: column; - gap: 10px; - align-items: flex-start; - padding: 16px; - border-radius: 16px; - border: 1px solid rgba(97, 174, 255, 0.18); - background: linear-gradient(180deg, rgba(14, 22, 38, 0.84), rgba(8, 12, 21, 0.92)); - color: var(--text); - text-align: left; - transition: transform 0.18s ease, border-color 0.18s ease; -} - -.platform-entry-card:hover { - transform: translateY(-1px); - border-color: rgba(97, 174, 255, 0.34); -} - -.platform-entry-card.is-static { - cursor: default; -} - -.platform-entry-card.is-static:hover { - transform: none; - border-color: rgba(97, 174, 255, 0.18); -} - -.platform-entry-card strong { - color: var(--title); -} - -.platform-entry-card span { - font-size: 12px; - color: var(--muted); -} - -.platform-node-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.platform-node-toolbar { - display: inline-flex; - align-items: center; - gap: 8px; - flex-wrap: wrap; -} - -.platform-node-card { - display: flex; - flex-direction: column; - gap: 10px; - align-items: stretch; - padding: 18px; - border-radius: 18px; - border: 1px solid rgba(97, 174, 255, 0.2); - background: linear-gradient(180deg, rgba(14, 22, 38, 0.84), rgba(8, 12, 21, 0.92)); - color: var(--text); - text-align: left; - transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; -} - -.platform-node-card:hover { - transform: translateY(-1px); - border-color: rgba(97, 174, 255, 0.38); - box-shadow: 0 16px 32px rgba(8, 25, 60, 0.12); -} - -.platform-node-card.is-disabled { - opacity: 0.8; -} - -.platform-node-card-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.platform-node-card-head-actions { - display: inline-flex; - align-items: center; - gap: 6px; - flex-wrap: wrap; - justify-content: flex-end; -} - -.platform-node-card-head strong { - display: block; - font-size: 17px; - font-weight: 800; - color: var(--title); -} - -.platform-node-card-id { - margin-top: 4px; - font-size: 11px; - color: var(--muted); -} - -.platform-node-card-meta { - font-size: 12px; - color: var(--muted); -} - -.platform-node-card-url { - font-size: 11px; - color: var(--muted); - word-break: break-all; -} - -.platform-node-card-url-muted { - color: rgba(68, 87, 145, 0.72); -} - -.platform-node-card-stats { - display: flex; - flex-wrap: wrap; - gap: 10px; - font-size: 12px; - color: var(--muted); -} - -.platform-node-card-capabilities { - display: flex; - flex-wrap: wrap; - gap: 8px; -} - -.platform-node-card-hint { - font-size: 12px; - color: var(--muted); -} - -.platform-node-card-last-seen { - font-size: 12px; - color: var(--muted); -} - -.platform-node-card-foot { - display: flex; - align-items: center; - justify-content: space-between; - gap: 12px; - padding-top: 6px; - color: var(--title); -} - -.platform-node-card-link { - font-size: 13px; - font-weight: 700; -} - -.platform-node-editor { - width: min(760px, 94vw); - max-height: min(88vh, 920px); - overflow-y: auto; -} - -.platform-node-editor-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; -} - -.platform-node-editor-span-2 { - grid-column: span 2; -} - -.platform-node-editor .field { - min-width: 0; -} - -.platform-node-editor .field-checkbox { - justify-content: flex-end; -} - -.platform-node-native-panel { - margin-top: 12px; - padding: 12px; - border-radius: 14px; - border: 1px solid var(--line); - background: color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%); - display: grid; - gap: 10px; -} - -.platform-node-native-panel-title { - font-size: 13px; - font-weight: 700; - color: var(--title); -} - -.platform-node-native-panel-grid { - display: grid; - gap: 10px; -} - -.check-row { - display: inline-flex; - align-items: center; - gap: 8px; - color: var(--text); -} - -.platform-node-test-result { - padding: 14px 16px; - border-radius: 16px; - border: 1px solid var(--line); - background: color-mix(in oklab, var(--panel) 74%, transparent); - display: flex; - flex-direction: column; - gap: 6px; - font-size: 13px; - max-height: 240px; - overflow: auto; -} - -.platform-node-test-result.is-ok { - border-color: color-mix(in oklab, var(--ok) 42%, var(--line) 58%); - background: color-mix(in oklab, var(--ok) 10%, var(--panel) 90%); -} - -.platform-node-test-result.is-error { - border-color: color-mix(in oklab, var(--err) 42%, var(--line) 58%); - background: color-mix(in oklab, var(--err) 8%, var(--panel) 92%); -} - -.platform-node-test-result-head, -.platform-node-test-result-meta, -.platform-node-editor-actions { - display: inline-flex; - align-items: center; - gap: 10px; - flex-wrap: wrap; -} - -.platform-node-test-kv { - display: grid; - gap: 6px; -} - -.platform-node-test-code { - display: block; - max-width: 100%; - overflow-x: auto; - white-space: nowrap; - padding: 6px 8px; - border-radius: 10px; - border: 1px solid var(--line); - background: color-mix(in oklab, var(--panel) 90%, transparent); -} - -.platform-node-direct-note { - padding: 12px 14px; - border-radius: 14px; - border: 1px solid var(--line); - background: color-mix(in oklab, var(--panel) 82%, transparent); - color: var(--muted); - font-size: 13px; - line-height: 1.6; -} - -.platform-node-current-target { - padding: 10px 12px; - border-radius: 14px; - border: 1px dashed color-mix(in oklab, var(--brand) 34%, var(--line) 66%); - background: color-mix(in oklab, var(--brand) 8%, var(--panel) 92%); - color: var(--title); - font-size: 12px; -} - -.node-workspace-page { - display: flex; - flex-direction: column; - min-height: 0; - height: 100%; -} - -.node-workspace-shell { - display: flex; - flex-direction: column; - gap: 18px; - flex: 0 0 auto; - min-height: 0; -} - -.node-workspace-summary-grid { - grid-template-columns: minmax(260px, 0.96fr) minmax(220px, 0.82fr) minmax(0, 2.22fr); -} - -.node-workspace-summary-grid .platform-resource-card { - grid-column: auto; -} - -.node-workspace-summary-card { - min-width: 0; -} - -.node-workspace-summary-value { - font-size: 22px; - font-weight: 800; - color: var(--title); - line-height: 1.2; - word-break: break-word; -} - -.node-workspace-summary-id { - margin-top: -2px; - font-size: 12px; - color: var(--muted); -} - -.node-workspace-chip-row { - display: flex; - flex-wrap: wrap; - gap: 8px; - margin-top: 2px; -} - -.node-workspace-resource-card { - min-width: 0; -} - -.node-workspace-content-shell { - display: flex; - flex: 1 1 auto; - min-height: 0; - margin-top: 24px; -} - -.node-workspace-content-shell > * { - flex: 1 1 auto; - min-height: 0; -} - -.platform-home-shell { - display: flex; - flex-direction: column; - gap: 18px; - min-height: 0; -} - -.platform-home-summary-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.platform-home-body { - display: grid; - grid-template-columns: minmax(0, 1.35fr) minmax(360px, 0.65fr); - gap: 18px; - align-items: start; -} - -.platform-home-node-section, -.platform-home-management-section { - min-width: 0; -} - -.platform-home-node-section .platform-node-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.platform-home-management-section .platform-entry-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); -} - -.platform-settings-shell { - max-width: min(1400px, 96vw); -} - -.platform-settings-info-card { - display: flex; - gap: 16px; - padding: 18px 20px; - border-radius: 18px; - border: 1px solid rgba(97, 174, 255, 0.28); - background: color-mix(in oklab, var(--panel) 68%, #dff0ff 32%); - color: var(--text); -} - -.platform-settings-info-icon { - width: 36px; - height: 36px; - min-width: 36px; - min-height: 36px; - flex: 0 0 36px; - aspect-ratio: 1 / 1; - border-radius: 999px; - display: inline-flex; - align-items: center; - justify-content: center; - background: #4273f2; - color: #fff; - font-weight: 800; -} - -.platform-settings-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; -} - -.platform-settings-search { - flex: 1 1 auto; - max-width: 460px; -} - -.platform-settings-table-wrap { - border: 1px solid var(--line); - border-radius: 0; - overflow: hidden; - max-height: 56vh; - overflow-y: auto; -} - -.platform-settings-table th, -.platform-settings-table td { - vertical-align: top; -} - -.platform-setting-public { - margin-top: 6px; - font-size: 12px; - color: #7bcf57; -} - -.platform-setting-value { - display: inline-block; - max-width: 320px; - white-space: pre-wrap; - word-break: break-word; -} - -.platform-settings-actions { - display: flex; - gap: 8px; -} - -.platform-setting-editor { - width: min(640px, 92vw); -} - -.platform-settings-pager { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - font-size: 12px; - color: var(--muted); -} - -.platform-template-shell { - max-width: min(1400px, 96vw); -} - -.platform-template-layout { - display: grid; - grid-template-columns: 280px minmax(0, 1fr); - gap: 16px; - min-height: 60vh; -} - -.platform-template-tabs { - display: flex; - flex-direction: column; - gap: 10px; - overflow-y: auto; - padding-right: 4px; -} - -.platform-template-tab { - display: flex; - flex-direction: column; - gap: 6px; - align-items: flex-start; - padding: 14px 16px; - border-radius: 16px; - border: 1px solid rgba(97, 174, 255, 0.16); - background: rgba(255, 255, 255, 0.03); - color: var(--text); - text-align: left; -} - -.platform-template-tab strong { - color: var(--title); -} - -.platform-template-tab span, -.platform-template-hint { - font-size: 12px; - color: var(--muted); - line-height: 1.6; -} - -.platform-template-tab.is-active { - border-color: rgba(97, 174, 255, 0.42); - background: rgba(97, 174, 255, 0.08); -} - -.platform-template-editor { - min-width: 0; - display: flex; - flex-direction: column; - gap: 12px; -} - -.platform-template-header { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 16px; -} - -.platform-template-textarea { - min-height: 0; - flex: 1 1 auto; -} - -.skill-market-modal-shell { - max-width: min(1480px, 96vw); - display: flex; - flex-direction: column; - min-height: min(920px, calc(100dvh - 48px)); -} - -.skill-market-browser-shell { - max-width: min(1400px, 96vw); - width: min(1400px, 96vw); - display: flex; - flex-direction: column; - min-height: min(920px, calc(100dvh - 48px)); -} - -.skill-market-page-shell { - width: 100%; - margin: 0; - padding: 18px; - border-radius: 22px; - gap: 18px; - display: flex; - flex-direction: column; - min-height: calc(100dvh - 126px); -} - -.skill-market-page-info-card { - display: flex; - align-items: center; - justify-content: space-between; - gap: 18px; - padding: 22px 24px; - border-radius: 22px; -} - -.skill-market-page-info-main { - display: flex; - align-items: flex-start; - gap: 16px; - min-width: 0; - flex: 1 1 auto; -} - -.skill-market-page-info-copy { - min-width: 0; - display: grid; - gap: 6px; -} - -.skill-market-page-info-copy strong { - display: block; - color: var(--title); - font-size: 17px; - line-height: 1.35; -} - -.skill-market-page-info-copy div { - color: var(--subtitle); - font-size: 13px; - line-height: 1.7; -} - -.skill-market-admin-toolbar { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - flex-wrap: wrap; -} - -.skill-market-search { - flex: 1 1 auto; - max-width: 560px; -} - -.skill-market-admin-actions { - display: flex; - gap: 10px; - flex-wrap: wrap; - align-items: center; -} - -.skill-market-create-btn { - display: inline-flex; - align-items: center; - justify-content: center; - gap: 8px; - white-space: nowrap; -} - -.skill-market-create-btn svg { - flex: 0 0 auto; -} - -.skill-market-page-workspace { - position: relative; - min-height: 0; - flex: 1 1 auto; - padding-top: 3px; - padding-right: 4px; - overflow: auto; -} - -.skill-market-card-grid { - display: grid; - grid-template-columns: repeat(2, minmax(0, 1fr)); - gap: 12px; - align-content: start; - min-height: 0; - padding-right: 4px; -} - -.skill-market-list-shell { - grid-template-columns: repeat(3, minmax(0, 1fr)); -} - -.skill-market-browser-grid { - display: grid; - grid-template-columns: repeat(3, minmax(0, 1fr)); - gap: 12px; - min-height: 0; - flex: 1 1 auto; - align-content: start; - grid-auto-rows: 1fr; - padding-top: 3px; -} - -.skill-market-card, -.skill-market-empty-card { - min-height: 188px; -} - -.skill-market-card { - display: flex; - flex-direction: column; - gap: 10px; - padding: 14px; - border-radius: 18px; - border: 1px solid color-mix(in oklab, var(--line) 72%, #f0b36a 28%); - background: - radial-gradient(circle at top right, color-mix(in oklab, var(--brand-soft) 36%, transparent), transparent 38%), - linear-gradient(180deg, color-mix(in oklab, var(--panel) 88%, #ffffff 12%), color-mix(in oklab, var(--panel) 96%, #f4eadf 4%)); - box-shadow: 0 14px 30px rgba(13, 24, 45, 0.12); - transition: transform 0.18s ease, border-color 0.18s ease, box-shadow 0.18s ease; -} - -.skill-market-card:hover, -.skill-market-card.is-active { - transform: translateY(-1px); - border-color: color-mix(in oklab, var(--brand) 44%, var(--line) 56%); - box-shadow: 0 18px 34px rgba(13, 24, 45, 0.16); -} - -.skill-market-card-top, -.skill-market-editor-head { - display: flex; - align-items: flex-start; - justify-content: space-between; - gap: 12px; -} - -.skill-market-card-title-wrap { - min-width: 0; -} - -.skill-market-card-title-wrap h4 { - margin: 0; - font-size: 17px; - line-height: 1.25; - color: var(--title); - word-break: break-word; -} - -.skill-market-card-key { - margin-top: 5px; - color: var(--muted); - font-size: 11px; - word-break: break-word; -} - -.skill-market-card-actions { - display: flex; - gap: 8px; -} - -.skill-market-card-desc { - margin: 0; - color: var(--subtitle); - font-size: 13px; - line-height: 1.55; - min-height: 60px; - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.skill-market-card-meta { - display: grid; - gap: 6px; - color: var(--muted); - font-size: 11px; -} - -.skill-market-card-meta span, -.skill-market-card-footer { - display: flex; - align-items: center; - gap: 8px; -} - -.skill-market-card-footer { - margin-top: auto; - justify-content: space-between; - gap: 12px; - padding-top: 10px; - border-top: 1px solid color-mix(in oklab, var(--line) 78%, transparent); - color: var(--muted); - font-size: 11px; -} - -.skill-market-card-status.is-ok { - color: #d98c1f; -} - -.skill-market-card-status.is-missing { - color: var(--err); -} - -.skill-market-browser-card { - min-height: 312px; - padding-bottom: 16px; -} - -.skill-market-browser-badge { - font-size: 11px; - padding: 6px 10px; - border-radius: 16px; -} - -.skill-market-browser-desc { - min-height: 80px; - -webkit-line-clamp: 4; -} - -.skill-market-browser-meta { - margin-top: auto; - gap: 8px; - font-size: 12px; -} - -.skill-market-browser-footer { - align-items: flex-end; -} - -.skill-market-install-btn { - min-height: 38px; - padding-inline: 14px; - border-radius: 16px; - box-shadow: 0 10px 24px rgba(43, 87, 199, 0.24); -} - -.skill-market-empty-card { - border: 1px dashed color-mix(in oklab, var(--line) 78%, var(--brand) 22%); - border-radius: 22px; - background: color-mix(in oklab, var(--panel) 92%, var(--brand-soft) 8%); -} - -.skill-market-editor { - display: flex; - flex-direction: column; - gap: 14px; - min-width: 0; - min-height: 0; -} - -.skill-market-editor-textarea { - min-height: 180px; -} - -.skill-market-upload-card { - display: grid; - gap: 10px; - padding: 14px; - border-radius: 14px; - border: 1px solid color-mix(in oklab, var(--line) 78%, var(--brand) 22%); - background: color-mix(in oklab, var(--panel) 92%, var(--brand-soft) 8%); -} - -.skill-market-upload-card.has-file { - border-color: color-mix(in oklab, var(--brand) 50%, var(--line) 50%); -} - -.skill-market-upload-foot { - color: var(--muted); - font-size: 12px; - line-height: 1.55; -} - -.skill-market-file-picker { - position: relative; - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; - min-height: 58px; - padding: 12px 14px; - border-radius: 12px; - border: 1px dashed color-mix(in oklab, var(--line) 60%, var(--brand) 40%); - background: color-mix(in oklab, var(--panel) 82%, #ffffff 18%); - color: var(--text); - cursor: pointer; - transition: border-color 0.18s ease, background 0.18s ease; -} - -.skill-market-file-picker:hover { - border-color: color-mix(in oklab, var(--brand) 58%, var(--line) 42%); - background: color-mix(in oklab, var(--panel) 74%, var(--brand-soft) 26%); -} - -.skill-market-file-picker input { - position: absolute; - inset: 0; - opacity: 0; - cursor: pointer; -} - -.skill-market-file-picker-copy { - min-width: 0; - display: grid; - gap: 0; -} - -.skill-market-file-picker-title { - color: var(--title); - font-size: 13px; - font-weight: 700; - line-height: 1.4; - word-break: break-word; -} - -.skill-market-file-picker-action { - flex: 0 0 auto; - display: inline-flex; - align-items: center; - justify-content: center; - min-height: 30px; - padding: 0 12px; - border-radius: 999px; - background: color-mix(in oklab, var(--brand) 14%, transparent); - color: var(--icon); - font-size: 12px; - font-weight: 700; -} - -.skill-market-browser-toolbar, -.skill-market-pager, -.row-actions-inline { - display: flex; - align-items: center; - justify-content: space-between; - gap: 14px; -} - -.skill-market-pager { - margin-top: 16px; - font-size: 12px; - color: var(--muted); -} - -.row-actions-inline { - justify-content: flex-end; - flex-wrap: wrap; -} - -.skill-market-page-size-hint { - white-space: nowrap; -} - -.skill-market-drawer-mask { - position: fixed; - inset: 0; - background: rgba(12, 18, 31, 0.26); - opacity: 0; - pointer-events: none; - transition: opacity 0.22s ease; - border-radius: 0; -} - -.skill-market-drawer-mask.is-open { - opacity: 1; - pointer-events: auto; -} - -.skill-market-drawer { - position: fixed; - top: 94px; - right: 18px; - bottom: 18px; - width: min(460px, calc(100vw - 36px)); - transform: translateX(calc(100% + 20px)); - transition: transform 0.22s ease; - z-index: 41; -} - -.skill-market-drawer.is-open { - transform: translateX(0); -} - -.skill-market-drawer .skill-market-editor { - height: 100%; - padding: 22px; - border-radius: 0; - box-shadow: 0 18px 42px rgba(13, 24, 45, 0.24); - overflow: auto; -} - -.app-shell[data-theme='light'] .skill-market-file-picker { - background: color-mix(in oklab, var(--panel) 80%, #f7fbff 20%); -} - -.app-shell[data-theme='light'] .skill-market-drawer-mask { - background: rgba(111, 138, 179, 0.16); -} - -.app-shell[data-theme='light'] .platform-entry-card { - border-color: #b7c7e6; - background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%); - color: #173057; -} - -.app-shell[data-theme='light'] .platform-node-card { - border-color: #b7c7e6; - background: linear-gradient(180deg, #f7fbff 0%, #edf4ff 100%); - color: #173057; -} - -.app-shell[data-theme='light'] .platform-node-card:hover { - border-color: #8fb4ef; - box-shadow: 0 14px 28px rgba(86, 118, 176, 0.12); -} - -.app-shell[data-theme='light'] .platform-node-card-head strong, -.app-shell[data-theme='light'] .platform-node-card-foot, -.app-shell[data-theme='light'] .platform-node-card-link, -.app-shell[data-theme='light'] .node-workspace-summary-value { - color: #173057; -} - -.app-shell[data-theme='light'] .platform-node-card-id, -.app-shell[data-theme='light'] .platform-node-card-meta, -.app-shell[data-theme='light'] .platform-node-card-url, -.app-shell[data-theme='light'] .platform-node-card-stats, -.app-shell[data-theme='light'] .platform-node-card-last-seen, -.app-shell[data-theme='light'] .platform-node-card-hint, -.app-shell[data-theme='light'] .node-workspace-summary-id { - color: #49648f; -} - -.app-shell[data-theme='light'] .platform-entry-card strong { - color: #173057; -} - -.app-shell[data-theme='light'] .platform-entry-card span { - color: #49648f; -} - -.app-shell[data-theme='light'] .platform-template-tab { - background: #f6f9ff; - border-color: #d4e1f7; -} - -.app-shell[data-theme='light'] .platform-template-tab.is-active { - background: #e9f1ff; - border-color: #8db4ff; -} - -.app-shell[data-theme='light'] .platform-selected-bot-last-body, -.app-shell[data-theme='light'] .platform-selected-bot-info, -.app-shell[data-theme='light'] .platform-image-row, -.app-shell[data-theme='light'] .platform-activity-row, -.app-shell[data-theme='light'] .platform-usage-row { - background: #f6f9ff; - border-color: #d4e1f7; -} - -.app-shell[data-theme='light'] .platform-resource-meter-track { - background: #e9eef9; -} - -.platform-usage-summary { - display: inline-flex; - gap: 16px; - font-size: 12px; - color: var(--muted); -} - -.platform-usage-table { - display: flex; - flex-direction: column; - gap: 6px; -} - -.platform-usage-head, -.platform-usage-row { - display: grid; - grid-template-columns: minmax(150px, 1.3fr) minmax(220px, 2fr) minmax(180px, 1.2fr) 90px 90px 90px minmax(130px, 1fr); - gap: 12px; - align-items: start; -} - -.platform-usage-head { - padding: 0 12px; - font-size: 11px; - text-transform: uppercase; - letter-spacing: 0.1em; - color: var(--muted); -} - -.platform-usage-row { - padding: 12px; - border-radius: 14px; - background: rgba(255, 255, 255, 0.03); - border: 1px solid rgba(255, 255, 255, 0.05); - font-size: 12px; -} - -.platform-usage-meta { - margin-top: 4px; - color: var(--muted); -} - -.platform-usage-content-cell, -.platform-usage-model { - min-width: 0; -} - -.platform-usage-preview { - color: var(--muted); - display: -webkit-box; - -webkit-line-clamp: 3; - -webkit-box-orient: vertical; - overflow: hidden; -} - -.platform-usage-pager { - display: flex; - align-items: center; - justify-content: space-between; - gap: 16px; - font-size: 12px; - color: var(--muted); -} - -.platform-usage-pager-actions { - display: inline-flex; - align-items: center; - gap: 8px; -} - -.pager-icon-btn { - width: 36px; - height: 36px; - border-radius: 999px; - border-width: 1px; - box-shadow: inset 0 0 0 1px color-mix(in oklab, var(--line) 55%, transparent); -} - -.pager-status { - font-size: 12px; - font-weight: 700; - color: var(--subtitle); -} - -.platform-last-action-modal { - width: min(760px, 92vw); -} - -.platform-modal { - max-height: 84vh; - overflow-y: auto; -} - -@media (max-width: 1400px) { - .grid-ops { - grid-template-columns: 280px 1fr 320px; - } - - .grid-ops.grid-ops-forced { - grid-template-columns: minmax(0, 1fr) 320px; - } - - .grid-ops.grid-ops-compact { - grid-template-columns: minmax(0, 1fr) minmax(260px, 360px); - } - - .platform-summary-grid { - grid-template-columns: repeat(3, minmax(0, 1fr)); - } - - .platform-home-summary-grid { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .node-workspace-summary-grid, - .platform-home-body { - grid-template-columns: minmax(0, 1fr); - } - - .platform-resource-card { - grid-column: span 3; - } -} - -@media (max-width: 1160px) { - .grid-2, - .grid-ops, - .wizard-steps, - .wizard-steps-4, - .factory-kpi-grid, - .summary-grid, - .wizard-agent-layout { - grid-template-columns: 1fr; - } - - .platform-grid, - .platform-home-body, - .platform-main-grid, - .platform-monitor-grid, - .platform-entry-grid, - .platform-summary-grid { - grid-template-columns: 1fr; - } - - .platform-home-node-section .platform-node-grid, - .platform-home-management-section .platform-entry-grid { - grid-template-columns: 1fr; - } - - .platform-resource-card { - grid-column: auto; - } - - .platform-template-layout { - grid-template-columns: 1fr; - } - - .skill-market-admin-layout, - .skill-market-card-grid, - .skill-market-browser-grid { - grid-template-columns: 1fr; - } - - .skill-market-list-shell { - grid-template-columns: repeat(2, minmax(0, 1fr)); - } - - .platform-template-tabs { - max-height: 220px; - } - - .platform-usage-head, - .platform-usage-row { - grid-template-columns: minmax(140px, 1.1fr) minmax(200px, 1.8fr) minmax(160px, 1fr) 70px 70px 70px 100px; - } - - .app-frame { - height: auto; - min-height: calc(100vh - 36px); - } - - .app-shell-compact .app-frame { - height: calc(100dvh - 24px); - min-height: calc(100dvh - 24px); - } - - .app-shell { - padding: 12px; - } - - .app-header-top { - flex-direction: column; - align-items: flex-start; - } - - .global-switches { - width: 100%; - justify-content: flex-start; - } - - .wizard-shell { - min-height: 640px; - } - - .platform-node-editor-grid { - grid-template-columns: 1fr; - } - - .platform-node-editor-span-2 { - grid-column: span 1; - } -} - -@media (max-width: 980px) { - .grid-ops.grid-ops-compact { - grid-template-columns: 1fr; - grid-template-rows: minmax(0, 1fr); - } - - .app-shell-compact .grid-ops.grid-ops-compact { - grid-template-columns: 1fr; - grid-template-rows: minmax(0, 1fr); - height: 100%; - min-height: 0; - } - - .platform-bot-list-panel { - min-height: calc(100dvh - 170px); - } - - .platform-bot-actions, - .platform-image-row, - .platform-activity-row { - flex-direction: column; - align-items: flex-start; - } - - .platform-selected-bot-headline { - gap: 8px; - } - - .platform-selected-bot-head { - flex-direction: column; - align-items: stretch; - } - - .platform-selected-bot-actions { - justify-content: flex-start; - } - - .platform-selected-bot-grid { - grid-template-columns: 1fr; - } - - .platform-resource-meter { - grid-template-columns: 24px minmax(0, 1fr) 64px; - } - - .platform-usage-head { - display: none; - } - - .platform-usage-row { - grid-template-columns: 1fr; - } - - .platform-selected-bot-last-row, - .platform-settings-pager, - .platform-usage-pager, - .platform-template-header, - .skill-market-admin-toolbar, - .skill-market-browser-toolbar, - .skill-market-pager, - .skill-market-page-info-card, - .skill-market-page-info-main, - .skill-market-editor-head, - .skill-market-card-top, - .skill-market-card-footer, - .row-actions-inline { - flex-direction: column; - align-items: stretch; - } - - .platform-compact-sheet-card { - max-height: 90dvh; - } - - .platform-compact-sheet-body { - max-height: calc(90dvh - 60px); - padding: 0 10px 12px; - } - - .skill-market-list-shell { - grid-template-columns: 1fr; - } - - .skill-market-drawer { - position: fixed; - top: 84px; - right: 12px; - bottom: 12px; - width: min(460px, calc(100vw - 24px)); - } - - .app-route-crumb { - width: 100%; - text-align: left; - } -} +@import './styles/app-theme.css'; +@import './styles/app-shell.css'; +@import './styles/app-common.css'; +@import './styles/app-platform.css'; diff --git a/frontend/src/App.mobile.css b/frontend/src/App.mobile.css new file mode 100644 index 0000000..096c15d --- /dev/null +++ b/frontend/src/App.mobile.css @@ -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); +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 5ab5db8..2749ec2 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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(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(`${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 ( -
-
- {!hideHeader ? ( -
{ - if (isCompactShell && headerCollapsed) setHeaderCollapsed(false); - }} - > -
-
- Nanobot -
-

{headerTitle}

- {route.kind === 'dashboard-skills' ? ( - - ) : route.kind === 'dashboard-node' ? ( - - ) : ( -
- {route.kind === 'dashboard' - ? t.platformHome - : t.botHome} -
- )} - {isCompactShell ? ( - - ) : null} -
-
- -
- {!headerCollapsed ? ( -
-
- - - - - - -
- -
- - - - - - -
-
- ) : null} -
-
-
- ) : null} - -
- {route.kind === 'dashboard' ? ( - - ) : route.kind === 'dashboard-node' ? ( - - ) : route.kind === 'dashboard-skills' ? ( - - ) : ( - - )} -
-
- - {shouldPromptSingleBotPassword ? ( -
-
event.stopPropagation()}> - Nanobot -

{forcedBot?.name || forcedBotId}

-

{locale === 'zh' ? '请输入该 Bot 的访问密码后继续。' : 'Enter the bot password to continue.'}

-
- { - 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 ?
{singleBotPasswordError}
: null} - -
-
-
- ) : null} -
- ); -} - -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)?.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(`${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 (
Nanobot -

{t.title}

-

{locale === 'zh' ? '正在校验面板访问权限...' : 'Checking panel access...'}

+

Nanobot

+

{isZh ? '正在检查登录状态...' : 'Checking session...'}

); } - if (required && !authenticated) { + if (!authBootstrap) { return ( -
-
-
- Nanobot -

{t.title}

-

{locale === 'zh' ? '请输入面板访问密码后继续。' : 'Enter the panel access password to continue.'}

-
- setPassword(event.target.value)} - onKeyDown={(event) => { - if (event.key === 'Enter') void onSubmit(); - }} - placeholder={locale === 'zh' ? '面板访问密码' : 'Panel access password'} - toggleLabels={passwordToggleLabels} - /> - {error ?
{error}
: null} - -
-
-
-
+ void handleLogin()} + defaultUsername={defaultUsername} + /> ); } - return children; -} - -function App() { return ( - - - + void handleLogout()} + onAuthBootstrapChange={handleBootstrapChange} + /> ); } +function App() { + const route = useAppRoute(); + return ; +} + export default App; diff --git a/frontend/src/app/AppChrome.tsx b/frontend/src/app/AppChrome.tsx new file mode 100644 index 0000000..1735409 --- /dev/null +++ b/frontend/src/app/AppChrome.tsx @@ -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 = { + '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 ( +
+
+ + +
+
+ + +
+
+ ); +} + +export function CompactHeaderSwitches() { + const { theme, setTheme, locale, setLocale } = useAppStore(); + const isZh = locale === 'zh'; + + return ( +
+ + +
+ ); +} + +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 ( +
+
+
+
+ Nanobot +

Nanobot

+

{locale === 'zh' ? '用户登录' : 'User Sign In'}

+
+
+ + onUsernameChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onSubmit(); + }} + placeholder={defaultUsername || (locale === 'zh' ? '用户名' : 'Username')} + autoFocus + /> + + onPasswordChange(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') onSubmit(); + }} + placeholder={locale === 'zh' ? '密码' : 'Password'} + toggleLabels={passwordToggleLabels} + /> + {error ?
{error}
: null} + +
+ {locale === 'zh' + ? `首次初始化默认账号:${defaultUsername || 'admin'}` + : `Initial seeded account: ${defaultUsername || 'admin'}`} +
+
+
+
+
+ ); +} + +type SidebarMenuProps = { + menus: SysMenuItem[]; + activeMenuKey: string; + onNavigate: (path: string) => void; +}; + +export function SidebarMenu({ menus, activeMenuKey, onNavigate }: SidebarMenuProps) { + const { locale } = useAppStore(); + + return ( + + ); +} + +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 ( +
+ + +
+ ); +} + +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 ( +
+ + {open && canRenderPortal ? createPortal( +
+
setOpen(false)}> +
event.stopPropagation()}> +
+ {isZh ? '切换 Bot' : 'Switch Bot'} + {isZh ? `${bots.length} 个` : `${bots.length}`} +
+
+ {bots.map((bot) => { + const active = bot.id === selectedBot?.id; + const itemTone = normalizeAssignedBotTone(bot.enabled, bot.docker_status); + return ( + + ); + })} +
+
+
+
, + document.body, + ) : null} +
+ ); +} diff --git a/frontend/src/app/AppShellViews.tsx b/frontend/src/app/AppShellViews.tsx new file mode 100644 index 0000000..c14c916 --- /dev/null +++ b/frontend/src/app/AppShellViews.tsx @@ -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 ( + + ); + case 'profile-usage-logs': + return ( + + ); + case 'profile-api-tokens': + return ( + + ); + case 'general-chat': + default: + return ( + + ); + } + }, [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 ( +
+
+
+
+ Nanobot + Nanobot +
+
+ + { + setSelectedChatBotId(botId); + navigateTo('/chat'); + }} + className="mobile-user-header-bot-switcher" + /> +
+
+ +
+ {mainContent} +
+ + +
+
+ ); +} + +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 ; + case 'general-chat': + return ( + + ); + case 'dashboard-edges': + return ; + case 'dashboard-node': + return ; + case 'admin-skills': + return ; + case 'admin-profile': + return ( + + ); + case 'admin-settings': + return ; + case 'admin-templates': + return ; + case 'admin-deploy': + return ; + case 'admin-users': + return ; + case 'admin-roles': + return ; + case 'profile-usage-logs': + return ( + + ); + case 'profile-api-tokens': + return ( + + ); + case 'bot': + return ; + } + }, [authBootstrap, compactMode, isNormalUser, isZh, onAuthBootstrapChange, onLogout, route, selectedChatBotId]); + + if (compactMode && isNormalUser) { + return ( + + ); + } + + return ( +
+
+ {compactMode ? ( + <> +
setSidebarOpen(false)} /> + + + ) : ( + + )} + +
+
+
+
+ {compactMode ? ( + + ) : null} +
+

{headerMeta.title}

+
{headerMeta.subtitle}
+
+
+ +
+ + {isNormalUser ? ( + { + setSelectedChatBotId(botId); + navigateTo('/chat'); + }} + className="sys-topbar-bot-switcher" + /> + ) : null} +
+
+
+ +
+ {mainContent} +
+
+
+
+ ); +} diff --git a/frontend/src/app/appRouteMeta.ts b/frontend/src/app/appRouteMeta.ts new file mode 100644 index 0000000..c737b55 --- /dev/null +++ b/frontend/src/app/appRouteMeta.ts @@ -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 { + const keys = new Set(); + 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; +} diff --git a/frontend/src/components/lucent/LucentDrawer.tsx b/frontend/src/components/lucent/LucentDrawer.tsx new file mode 100644 index 0000000..ec82199 --- /dev/null +++ b/frontend/src/components/lucent/LucentDrawer.tsx @@ -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 ( + <> +