From ca1f941e4c6606b509d63cd986360ddf71615500 Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Fri, 3 Apr 2026 23:00:08 +0800 Subject: [PATCH] v0.1.4-p5 --- ( | 0 .env.prod.example | 6 + backend/.env.example | 6 + backend/api/bot_router.py | 47 +- backend/api/bot_runtime_router.py | 231 ++---- backend/api/platform_router.py | 20 + backend/api/system_router.py | 35 +- backend/app_factory.py | 5 +- backend/bootstrap/auth_access.py | 60 ++ backend/core/auth_middleware.py | 141 +--- backend/core/cache.py | 74 +- backend/core/database.py | 42 + backend/core/settings.py | 37 + backend/models/auth.py | 23 + backend/schemas/platform.py | 27 + backend/services/bot_runtime_service.py | 206 +++++ backend/services/platform_auth_service.py | 480 ++++++++++++ .../services/platform_login_log_service.py | 100 +++ .../platform_runtime_settings_service.py | 20 + backend/services/platform_service.py | 3 + backend/services/platform_settings_core.py | 40 +- backend/services/platform_settings_service.py | 2 + .../platform_system_settings_service.py | 46 +- design/code-structure-standards.md | 59 +- frontend/.env.example | 4 +- frontend/src/App.css | 20 + frontend/src/App.tsx | 300 ++------ frontend/src/app/BotRouteAccessGate.tsx | 222 ++++++ frontend/src/app/PanelLoginGate.tsx | 179 +++++ frontend/src/hooks/useBotsSync.ts | 16 +- .../modules/dashboard/BotDashboardModule.tsx | 1 - .../src/modules/dashboard/chat/chatUtils.ts | 97 +++ .../dashboard/components/BotDashboardView.tsx | 63 +- .../components/DashboardAgentFilesModal.tsx | 76 ++ ...ls.tsx => DashboardChannelConfigModal.tsx} | 335 +------- .../components/DashboardChatComposer.tsx | 403 ++++++++++ .../components/DashboardChatPanel.tsx | 417 ++-------- .../components/DashboardChatPanel.types.ts | 36 + .../components/DashboardConfigModals.tsx | 6 +- .../DashboardConversationMessages.tsx | 9 +- .../components/DashboardCronJobsModal.tsx | 135 ++++ .../components/DashboardEnvParamsModal.tsx | 262 +++++++ .../components/DashboardModalStack.tsx | 13 +- .../DashboardRuntimeActionModal.tsx | 37 + .../DashboardStagedSubmissionQueue.tsx | 70 ++ .../components/DashboardSupportModals.tsx | 601 --------------- .../DashboardTemplateManagerModal.tsx | 116 +++ .../components/DashboardTopicConfigModal.tsx | 338 ++++++++ .../dashboard/components/RuntimePanel.tsx | 6 +- .../components/SkillMarketInstallModal.tsx | 6 +- .../components/WorkspaceEntriesList.tsx | 100 --- .../components/WorkspaceHoverCard.tsx | 56 -- .../dashboard/components/WorkspaceOverlay.css | 382 --------- .../components/WorkspacePreviewModal.tsx | 219 ------ .../dashboard/config-managers/topicManager.ts | 2 +- .../src/modules/dashboard/configManagers.ts | 3 - frontend/src/modules/dashboard/constants.ts | 25 - .../dashboard/hooks/dashboardChatShared.ts | 15 + .../dashboard/hooks/useBotDashboardModule.ts | 35 +- .../hooks/useDashboardChannelConfig.ts | 212 +++++ .../hooks/useDashboardChatCommandDispatch.ts | 280 +++++++ .../hooks/useDashboardChatComposer.ts | 335 ++------ .../hooks/useDashboardChatHistory.ts | 250 +----- .../hooks/useDashboardChatMessageActions.ts | 244 ++++++ .../hooks/useDashboardChatStaging.ts | 117 +++ .../hooks/useDashboardConfigPanels.ts | 379 +++------ .../hooks/useDashboardConversation.ts | 21 +- .../hooks/useDashboardDerivedState.ts | 61 +- .../dashboard/hooks/useDashboardMcpConfig.ts | 150 ++++ .../dashboard/hooks/useDashboardShellState.ts | 2 +- .../hooks/useDashboardSkillsConfig.ts | 82 +- .../hooks/useDashboardSystemDefaults.ts | 3 +- .../hooks/useDashboardTemplateManager.ts | 2 +- .../hooks/useDashboardTopicConfig.ts | 119 +-- .../dashboard/hooks/useDashboardVoiceInput.ts | 2 +- .../dashboard/hooks/useDashboardWorkspace.ts | 727 ------------------ .../channelTopicModalProps.ts | 267 ------- .../skillsMcpModalProps.ts | 196 ----- .../supportModalProps.ts | 94 --- .../dashboard/shared/configPanelModalProps.ts | 3 - .../dashboard/shared/workspaceMarkdown.tsx | 302 -------- .../dashboard/topic/TopicFeedPanel.tsx | 8 +- .../dashboard/topic/topicPresetUtils.ts | 62 ++ frontend/src/modules/dashboard/types.ts | 54 -- frontend/src/modules/dashboard/utils.tsx | 335 +------- .../platform/PlatformAdminDashboardPage.tsx | 2 +- .../platform/PlatformBotManagementPage.tsx | 69 +- .../platform/PlatformDashboardPage.css | 33 + .../modules/platform/PlatformLoginLogPage.tsx | 234 ++++++ frontend/src/modules/platform/api/settings.ts | 14 +- .../components/PlatformBotRuntimeSection.tsx | 261 ++----- .../components/PlatformDashboardModals.tsx | 20 +- ...ingsModal.tsx => PlatformSettingsPage.tsx} | 0 ...erModal.tsx => SkillMarketManagerPage.tsx} | 0 ...nagerModal.tsx => TemplateManagerPage.tsx} | 0 .../hooks/usePlatformBotDockerLogs.ts | 178 +++++ .../platform/hooks/usePlatformDashboard.ts | 91 ++- frontend/src/modules/platform/types.ts | 27 + frontend/src/shared/bot/sortBots.ts | 18 + .../text/messageText.ts} | 75 +- .../ui/ModalCardShell.tsx} | 8 +- .../ui/PreviewModalShell.tsx} | 8 +- frontend/src/utils/appRoute.ts | 23 + frontend/src/utils/botAccess.ts | 47 +- frontend/src/utils/panelAccess.ts | 69 +- frontend/vite.config.ts | 51 ++ 106 files changed, 5807 insertions(+), 6043 deletions(-) create mode 100644 ( create mode 100644 backend/bootstrap/auth_access.py create mode 100644 backend/models/auth.py create mode 100644 backend/services/bot_runtime_service.py create mode 100644 backend/services/platform_auth_service.py create mode 100644 backend/services/platform_login_log_service.py create mode 100644 frontend/src/app/BotRouteAccessGate.tsx create mode 100644 frontend/src/app/PanelLoginGate.tsx create mode 100644 frontend/src/modules/dashboard/chat/chatUtils.ts create mode 100644 frontend/src/modules/dashboard/components/DashboardAgentFilesModal.tsx rename frontend/src/modules/dashboard/components/{DashboardChannelTopicModals.tsx => DashboardChannelConfigModal.tsx} (60%) create mode 100644 frontend/src/modules/dashboard/components/DashboardChatComposer.tsx create mode 100644 frontend/src/modules/dashboard/components/DashboardChatPanel.types.ts create mode 100644 frontend/src/modules/dashboard/components/DashboardCronJobsModal.tsx create mode 100644 frontend/src/modules/dashboard/components/DashboardEnvParamsModal.tsx create mode 100644 frontend/src/modules/dashboard/components/DashboardRuntimeActionModal.tsx create mode 100644 frontend/src/modules/dashboard/components/DashboardStagedSubmissionQueue.tsx delete mode 100644 frontend/src/modules/dashboard/components/DashboardSupportModals.tsx create mode 100644 frontend/src/modules/dashboard/components/DashboardTemplateManagerModal.tsx create mode 100644 frontend/src/modules/dashboard/components/DashboardTopicConfigModal.tsx delete mode 100644 frontend/src/modules/dashboard/components/WorkspaceEntriesList.tsx delete mode 100644 frontend/src/modules/dashboard/components/WorkspaceHoverCard.tsx delete mode 100644 frontend/src/modules/dashboard/components/WorkspaceOverlay.css delete mode 100644 frontend/src/modules/dashboard/components/WorkspacePreviewModal.tsx delete mode 100644 frontend/src/modules/dashboard/configManagers.ts create mode 100644 frontend/src/modules/dashboard/hooks/dashboardChatShared.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChannelConfig.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatCommandDispatch.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatMessageActions.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardChatStaging.ts create mode 100644 frontend/src/modules/dashboard/hooks/useDashboardMcpConfig.ts delete mode 100644 frontend/src/modules/dashboard/hooks/useDashboardWorkspace.ts delete mode 100644 frontend/src/modules/dashboard/shared/config-panel-modal-props/channelTopicModalProps.ts delete mode 100644 frontend/src/modules/dashboard/shared/config-panel-modal-props/skillsMcpModalProps.ts delete mode 100644 frontend/src/modules/dashboard/shared/config-panel-modal-props/supportModalProps.ts delete mode 100644 frontend/src/modules/dashboard/shared/configPanelModalProps.ts delete mode 100644 frontend/src/modules/dashboard/shared/workspaceMarkdown.tsx create mode 100644 frontend/src/modules/dashboard/topic/topicPresetUtils.ts create mode 100644 frontend/src/modules/platform/PlatformLoginLogPage.tsx rename frontend/src/modules/platform/components/{PlatformSettingsModal.tsx => PlatformSettingsPage.tsx} (100%) rename frontend/src/modules/platform/components/{SkillMarketManagerModal.tsx => SkillMarketManagerPage.tsx} (100%) rename frontend/src/modules/platform/components/{TemplateManagerModal.tsx => TemplateManagerPage.tsx} (100%) create mode 100644 frontend/src/modules/platform/hooks/usePlatformBotDockerLogs.ts create mode 100644 frontend/src/shared/bot/sortBots.ts rename frontend/src/{modules/dashboard/messageParser.ts => shared/text/messageText.ts} (61%) rename frontend/src/{modules/dashboard/components/DashboardModalCardShell.tsx => shared/ui/ModalCardShell.tsx} (86%) rename frontend/src/{modules/dashboard/components/DashboardPreviewModalShell.tsx => shared/ui/PreviewModalShell.tsx} (86%) diff --git a/( b/( new file mode 100644 index 0000000..e69de29 diff --git a/.env.prod.example b/.env.prod.example index c2b8c4c..791f524 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -50,6 +50,12 @@ DEFAULT_BOT_SYSTEM_TIMEZONE=Asia/Shanghai # Panel access protection PANEL_ACCESS_PASSWORD=change_me_panel_password +# Browser credential requests must use an explicit CORS allowlist. +# If frontend and backend are served under the same origin via nginx `/api` proxy, +# this can usually stay unset. Otherwise set the real dashboard origin(s). +# Example: +# CORS_ALLOWED_ORIGINS=https://dashboard.example.com + # Max upload size for backend validation (MB) UPLOAD_MAX_MB=200 diff --git a/backend/.env.example b/backend/.env.example index df0bda9..42aa97d 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -24,6 +24,12 @@ REDIS_DEFAULT_TTL=60 # Optional panel-level access password for all backend API/WS calls. PANEL_ACCESS_PASSWORD= + +# Explicit CORS allowlist for browser credential requests. +# For local development, the backend defaults to common Vite dev origins. +# In production, prefer same-origin `/api` reverse proxy, or set your real dashboard origin explicitly. +# Example: +# CORS_ALLOWED_ORIGINS=http://localhost:5173,https://dashboard.example.com # The following platform-level items are now managed in sys_setting / 平台参数: # - page_size # - chat_pull_page_size diff --git a/backend/api/bot_router.py b/backend/api/bot_router.py index 1fbf1d5..8fed3fd 100644 --- a/backend/api/bot_router.py +++ b/backend/api/bot_router.py @@ -1,8 +1,16 @@ -from fastapi import APIRouter, Depends +from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlmodel import Session from core.database import get_session +from models.bot import BotInstance from schemas.bot import BotCreateRequest, BotPageAuthLoginRequest, BotUpdateRequest +from services.platform_auth_service import ( + clear_bot_token_cookie, + create_bot_token, + resolve_bot_request_auth, + revoke_bot_token, + set_bot_token_cookie, +) from services.bot_management_service import ( authenticate_bot_page_access, create_bot_record, @@ -36,8 +44,41 @@ def get_bot_detail(bot_id: str, session: Session = Depends(get_session)): @router.post("/api/bots/{bot_id}/auth/login") -def login_bot_page(bot_id: str, payload: BotPageAuthLoginRequest, session: Session = Depends(get_session)): - return authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password) +def login_bot_page( + bot_id: str, + payload: BotPageAuthLoginRequest, + request: Request, + response: Response, + session: Session = Depends(get_session), +): + result = authenticate_bot_page_access(session, bot_id=bot_id, password=payload.password) + try: + raw_token = create_bot_token(session, request, bot_id) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + set_bot_token_cookie(response, request, bot_id, raw_token, session) + return result + + +@router.get("/api/bots/{bot_id}/auth/status") +def get_bot_auth_status(bot_id: str, request: Request, session: Session = Depends(get_session)): + bot = session.get(BotInstance, bot_id) + if not bot: + return {"enabled": False, "authenticated": False, "auth_source": None, "bot_id": bot_id} + principal = resolve_bot_request_auth(session, request, bot_id) + return { + "enabled": bool(str(bot.access_password or "").strip()), + "authenticated": bool(principal.authenticated), + "auth_source": principal.auth_source if principal.authenticated else None, + "bot_id": bot_id, + } + + +@router.post("/api/bots/{bot_id}/auth/logout") +def logout_bot_page(bot_id: str, request: Request, response: Response, session: Session = Depends(get_session)): + revoke_bot_token(session, request, bot_id) + clear_bot_token_cookie(response, bot_id) + return {"success": True, "bot_id": bot_id} @router.put("/api/bots/{bot_id}") diff --git a/backend/api/bot_runtime_router.py b/backend/api/bot_runtime_router.py index e40fd95..0354e5d 100644 --- a/backend/api/bot_runtime_router.py +++ b/backend/api/bot_runtime_router.py @@ -1,73 +1,27 @@ import logging -import time -from datetime import datetime -from pathlib import Path -from typing import Any, Dict, List, Optional -from zoneinfo import ZoneInfo +from typing import Optional from fastapi import APIRouter, Depends, HTTPException, WebSocket, WebSocketDisconnect from sqlmodel import Session from core.database import engine, get_session from core.docker_instance import docker_manager -from core.settings import BOTS_WORKSPACE_ROOT from core.websocket_manager import manager -from models.bot import BotInstance -from services.bot_channel_service import _get_bot_channels_from_config -from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance -from services.bot_storage_service import _read_bot_config, _write_bot_config -from services.bot_storage_service import _read_cron_store, _write_cron_store +from services.bot_runtime_service import ( + delete_cron_job as delete_cron_job_service, + ensure_monitor_websocket_access, + get_bot_logs as get_bot_logs_service, + list_cron_jobs as list_cron_jobs_service, + relogin_weixin as relogin_weixin_service, + start_cron_job as start_cron_job_service, + stop_cron_job as stop_cron_job_service, +) from services.runtime_service import docker_callback router = APIRouter() logger = logging.getLogger("dashboard.backend") -def _now_ms() -> int: - return int(time.time() * 1000) - - -def _compute_cron_next_run(schedule: Dict[str, Any], now_ms: Optional[int] = None) -> Optional[int]: - current_ms = int(now_ms or _now_ms()) - kind = str(schedule.get("kind") or "").strip().lower() - - if kind == "at": - at_ms = int(schedule.get("atMs") or 0) - return at_ms if at_ms > current_ms else None - - if kind == "every": - every_ms = int(schedule.get("everyMs") or 0) - return current_ms + every_ms if every_ms > 0 else None - - if kind == "cron": - expr = str(schedule.get("expr") or "").strip() - if not expr: - return None - try: - from croniter import croniter - - tz_name = str(schedule.get("tz") or "").strip() - tz = ZoneInfo(tz_name) if tz_name else datetime.now().astimezone().tzinfo - base_dt = datetime.fromtimestamp(current_ms / 1000, tz=tz) - next_dt = croniter(expr, base_dt).get_next(datetime) - return int(next_dt.timestamp() * 1000) - except Exception: - return None - - return None - - -def _get_bot_or_404(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 _weixin_state_file_path(bot_id: str) -> Path: - return Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json" - - @router.get("/api/bots/{bot_id}/logs") def get_bot_logs( bot_id: str, @@ -77,150 +31,72 @@ def get_bot_logs( reverse: bool = False, session: Session = Depends(get_session), ): - _get_bot_or_404(session, bot_id) - if limit is not None: - page = docker_manager.get_logs_page( - bot_id, - offset=max(0, int(offset)), - limit=max(1, int(limit)), - reverse=bool(reverse), + try: + return get_bot_logs_service( + session, + bot_id=bot_id, + tail=tail, + offset=offset, + limit=limit, + reverse=reverse, ) - return {"bot_id": bot_id, **page} - effective_tail = max(1, int(tail or 300)) - return {"bot_id": bot_id, "logs": docker_manager.get_recent_logs(bot_id, tail=effective_tail)} + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/api/bots/{bot_id}/weixin/relogin") async def relogin_weixin(bot_id: str, session: Session = Depends(get_session)): - bot = _get_bot_or_404(session, bot_id) - weixin_channel = next( - ( - row - for row in _get_bot_channels_from_config(bot) - if str(row.get("channel_type") or "").strip().lower() == "weixin" - ), - None, - ) - if not weixin_channel: - raise HTTPException(status_code=404, detail="Weixin channel not found") - - state_file = _weixin_state_file_path(bot_id) - removed = False try: - if state_file.is_file(): - state_file.unlink() - removed = True - except Exception as exc: - raise HTTPException(status_code=500, detail=f"Failed to remove weixin state: {exc}") from exc - - config_data = _read_bot_config(bot_id) - channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {} - weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None - if isinstance(weixin_cfg, dict) and "token" in weixin_cfg: - weixin_cfg.pop("token", None) - _write_bot_config(bot_id, config_data) - - restarted = False - if str(bot.docker_status or "").upper() == "RUNNING": - stop_bot_instance(session, bot_id) - await start_bot_instance(session, bot_id) - restarted = True - - return { - "status": "relogin_started", - "bot_id": bot_id, - "removed_state": removed, - "restarted": restarted, - } + return await relogin_weixin_service(session, bot_id=bot_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except ValueError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc + except RuntimeError as exc: + raise HTTPException(status_code=500, detail=str(exc)) from exc @router.get("/api/bots/{bot_id}/cron/jobs") def list_cron_jobs(bot_id: str, include_disabled: bool = True, session: Session = Depends(get_session)): - _get_bot_or_404(session, bot_id) - 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 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} + try: + return list_cron_jobs_service(session, bot_id=bot_id, include_disabled=include_disabled) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @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)): - _get_bot_or_404(session, bot_id) - 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"] = _now_ms() - state = found.get("state") - if not isinstance(state, dict): - state = {} - found["state"] = state - state["nextRunAtMs"] = None - _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs}) - return {"status": "stopped", "job_id": job_id} + try: + return stop_cron_job_service(session, bot_id=bot_id, job_id=job_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.post("/api/bots/{bot_id}/cron/jobs/{job_id}/start") def start_cron_job(bot_id: str, job_id: str, session: Session = Depends(get_session)): - _get_bot_or_404(session, bot_id) - 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"] = True - found["updatedAtMs"] = _now_ms() - state = found.get("state") - if not isinstance(state, dict): - state = {} - found["state"] = state - schedule = found.get("schedule") - state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {}) - _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs}) - return {"status": "started", "job_id": job_id} + try: + return start_cron_job_service(session, bot_id=bot_id, job_id=job_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @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)): - _get_bot_or_404(session, bot_id) - 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} + try: + return delete_cron_job_service(session, bot_id=bot_id, job_id=job_id) + except LookupError as exc: + raise HTTPException(status_code=404, detail=str(exc)) from exc @router.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: + try: + ensure_monitor_websocket_access(session, websocket, bot_id) + except PermissionError: + await websocket.close(code=4401, reason="Bot or panel authentication required") + return + except LookupError: await websocket.close(code=4404, reason="Bot not found") return @@ -240,6 +116,15 @@ async def websocket_endpoint(websocket: WebSocket, bot_id: str): try: while True: await websocket.receive_text() + with Session(engine) as session: + try: + ensure_monitor_websocket_access(session, websocket, bot_id) + except PermissionError: + await websocket.close(code=4401, reason="Authentication expired") + return + except LookupError: + await websocket.close(code=4404, reason="Bot not found") + return except WebSocketDisconnect: pass except RuntimeError as exc: diff --git a/backend/api/platform_router.py b/backend/api/platform_router.py index ab522bc..587b3d6 100644 --- a/backend/api/platform_router.py +++ b/backend/api/platform_router.py @@ -13,6 +13,7 @@ from services.platform_service import ( get_bot_activity_stats, get_platform_settings, list_system_settings, + list_login_logs, list_activity_events, list_usage, save_platform_settings, @@ -78,6 +79,25 @@ def get_platform_events(bot_id: Optional[str] = None, limit: int = 100, session: return {"items": list_activity_events(session, bot_id=bot_id, limit=limit)} +@router.get("/api/platform/login-logs") +def get_platform_login_logs( + search: str = "", + auth_type: str = "", + status: str = "all", + limit: int = 50, + offset: int = 0, + session: Session = Depends(get_session), +): + return list_login_logs( + session, + search=search, + auth_type=auth_type, + status=status, + limit=limit, + offset=offset, + ).model_dump() + + @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)} diff --git a/backend/api/system_router.py b/backend/api/system_router.py index 8791b27..e4ea186 100644 --- a/backend/api/system_router.py +++ b/backend/api/system_router.py @@ -1,6 +1,6 @@ from typing import Any, Dict -from fastapi import APIRouter, HTTPException +from fastapi import APIRouter, Depends, HTTPException, Request, Response from sqlmodel import Session, select from core.database import engine, get_session @@ -9,6 +9,13 @@ from core.utils import _get_default_system_timezone from models.bot import BotInstance from schemas.system import PanelLoginRequest, SystemTemplatesUpdateRequest from core.cache import cache +from services.platform_auth_service import ( + clear_panel_token_cookie, + create_panel_token, + resolve_panel_request_auth, + revoke_panel_token, + set_panel_token_cookie, +) from services.platform_service import get_platform_settings_snapshot, get_speech_runtime_settings from services.template_service import ( get_agent_md_templates, @@ -21,19 +28,37 @@ router = APIRouter() @router.get("/api/panel/auth/status") -def get_panel_auth_status(): +def get_panel_auth_status(request: Request, session: Session = Depends(get_session)): configured = str(PANEL_ACCESS_PASSWORD or "").strip() - return {"enabled": bool(configured)} + principal = resolve_panel_request_auth(session, request) + return { + "enabled": bool(configured), + "authenticated": bool(principal.authenticated), + "auth_source": principal.auth_source if principal.authenticated else None, + } @router.post("/api/panel/auth/login") -def panel_login(payload: PanelLoginRequest): +def panel_login(payload: PanelLoginRequest, request: Request, response: Response, session: Session = Depends(get_session)): configured = str(PANEL_ACCESS_PASSWORD or "").strip() if not configured: + clear_panel_token_cookie(response) 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} + try: + raw_token = create_panel_token(session, request) + except RuntimeError as exc: + raise HTTPException(status_code=503, detail=str(exc)) from exc + set_panel_token_cookie(response, request, raw_token, session) + return {"success": True, "enabled": True, "authenticated": True} + + +@router.post("/api/panel/auth/logout") +def panel_logout(request: Request, response: Response, session: Session = Depends(get_session)): + revoke_panel_token(session, request) + clear_panel_token_cookie(response) + return {"success": True} @router.get("/api/system/defaults") def get_system_defaults(): diff --git a/backend/app_factory.py b/backend/app_factory.py index 43a0377..0cda5a8 100644 --- a/backend/app_factory.py +++ b/backend/app_factory.py @@ -19,7 +19,7 @@ from api.workspace_router import router as workspace_router from bootstrap.app_runtime import register_app_runtime from core.auth_middleware import PasswordProtectionMiddleware from core.docker_instance import docker_manager -from core.settings import BOTS_WORKSPACE_ROOT, DATA_ROOT +from core.settings import BOTS_WORKSPACE_ROOT, CORS_ALLOWED_ORIGINS, DATA_ROOT from core.speech_service import WhisperSpeechService @@ -33,9 +33,10 @@ def create_app() -> FastAPI: app.add_middleware(PasswordProtectionMiddleware) app.add_middleware( CORSMiddleware, - allow_origins=["*"], + allow_origins=list(CORS_ALLOWED_ORIGINS), allow_methods=["*"], allow_headers=["*"], + allow_credentials=True, ) app.include_router(platform_router) diff --git a/backend/bootstrap/auth_access.py b/backend/bootstrap/auth_access.py new file mode 100644 index 0000000..54d28ab --- /dev/null +++ b/backend/bootstrap/auth_access.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +import re +from enum import Enum +from typing import Optional + + +class RouteAccessMode(str, Enum): + PUBLIC = "public" + PANEL_ONLY = "panel_only" + BOT_OR_PANEL = "bot_or_panel" + PUBLIC_BOT_OR_PANEL = "public_bot_or_panel" + + +_BOT_ID_API_RE = re.compile(r"^/api/bots/([^/]+)(?:/.*)?$") +_BOT_ID_PUBLIC_RE = re.compile(r"^/public/bots/([^/]+)(?:/.*)?$") +_BOT_PANEL_ONLY_ROUTE_METHODS = [ + (re.compile(r"^/api/bots/[^/]+$"), {"DELETE"}), + (re.compile(r"^/api/bots/[^/]+/(?:enable|disable|deactivate)$"), {"POST"}), +] + +_PUBLIC_PATHS = { + "/api/panel/auth/status", + "/api/panel/auth/login", + "/api/panel/auth/logout", + "/api/health", + "/api/health/cache", + "/api/system/defaults", +} + +_BOT_PUBLIC_AUTH_RE = re.compile(r"^/api/bots/[^/]+/auth/(?:login|logout|status)$") + + +def extract_bot_id(path: str) -> Optional[str]: + raw = str(path or "").strip() + match = _BOT_ID_API_RE.match(raw) or _BOT_ID_PUBLIC_RE.match(raw) + if not match or not match.group(1): + return None + return match.group(1).strip() or None + + +def resolve_route_access_mode(path: str, method: str) -> RouteAccessMode: + raw_path = str(path or "").strip() + verb = str(method or "GET").strip().upper() + + if raw_path in _PUBLIC_PATHS or _BOT_PUBLIC_AUTH_RE.fullmatch(raw_path): + return RouteAccessMode.PUBLIC + + if raw_path.startswith("/public/bots/"): + return RouteAccessMode.PUBLIC_BOT_OR_PANEL + + if _BOT_ID_API_RE.fullmatch(raw_path): + if any(pattern.fullmatch(raw_path) and verb in methods for pattern, methods in _BOT_PANEL_ONLY_ROUTE_METHODS): + return RouteAccessMode.PANEL_ONLY + return RouteAccessMode.BOT_OR_PANEL + + if raw_path.startswith("/api/"): + return RouteAccessMode.PANEL_ONLY + + return RouteAccessMode.PUBLIC diff --git a/backend/core/auth_middleware.py b/backend/core/auth_middleware.py index 48b9c30..72c6d55 100644 --- a/backend/core/auth_middleware.py +++ b/backend/core/auth_middleware.py @@ -1,125 +1,50 @@ from __future__ import annotations -from typing import Optional - from fastapi import Request from fastapi.responses import JSONResponse +from sqlmodel import Session from starlette.middleware.base import BaseHTTPMiddleware -from core.settings import PANEL_ACCESS_PASSWORD -from services.bot_storage_service import _read_bot_config - -PANEL_ACCESS_PASSWORD_HEADER = "x-panel-password" -BOT_ACCESS_PASSWORD_HEADER = "X-Bot-Access-Password" -BOT_PANEL_ONLY_SUFFIXES = {"/enable", "/disable", "/deactivate"} +from bootstrap.auth_access import RouteAccessMode, extract_bot_id, resolve_route_access_mode +from core.database import engine +from services.platform_auth_service import ( + resolve_bot_request_auth, + resolve_panel_request_auth, +) -def _extract_bot_id_from_api_path(path: str) -> Optional[str]: - parts = [p for p in path.split("/") if p.strip()] - if len(parts) >= 3 and parts[0] == "api" and parts[1] == "bots": - return parts[2] - return 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_id: str, supplied: str) -> Optional[str]: - config = _read_bot_config(bot_id) - configured = str(config.get("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_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_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 - if _extract_bot_id_from_api_path(raw): - return False - return True +def _unauthorized(detail: str) -> JSONResponse: + return JSONResponse(status_code=401, content={"detail": detail}) class PasswordProtectionMiddleware(BaseHTTPMiddleware): async def dispatch(self, request: Request, call_next): + if request.method.upper() == "OPTIONS": + return await call_next(request) + path = request.url.path - method = request.method.upper() - - if method == "OPTIONS": + access_mode = resolve_route_access_mode(path, request.method) + if access_mode == RouteAccessMode.PUBLIC: return await call_next(request) - bot_id = _extract_bot_id_from_api_path(path) - if not bot_id: - if _is_panel_protected_api_path(path, 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) + bot_id = extract_bot_id(path) + with Session(engine) as session: + panel_principal = resolve_panel_request_auth(session, request) + if panel_principal.authenticated: + request.state.auth_principal = panel_principal + return await call_next(request) - if _is_bot_panel_management_api_path(path, method): - panel_error = _validate_panel_access_password(_get_supplied_panel_password_http(request)) - if panel_error: - bot_error = _validate_bot_access_password(bot_id, _get_supplied_bot_access_password_http(request)) - if bot_error: - return JSONResponse(status_code=401, content={"detail": bot_error}) + if access_mode == RouteAccessMode.PANEL_ONLY: + return _unauthorized("Panel authentication required") - return await call_next(request) + if not bot_id: + return _unauthorized("Bot authentication required") + + bot_principal = resolve_bot_request_auth(session, request, bot_id) + if bot_principal.authenticated: + request.state.auth_principal = bot_principal + return await call_next(request) + + if access_mode == RouteAccessMode.PUBLIC_BOT_OR_PANEL: + return _unauthorized("Bot or panel authentication required to access this resource") + return _unauthorized("Bot or panel authentication required") diff --git a/backend/core/cache.py b/backend/core/cache.py index 738725d..d36f075 100644 --- a/backend/core/cache.py +++ b/backend/core/cache.py @@ -1,3 +1,5 @@ +from __future__ import annotations + import json from typing import Any, Optional @@ -10,10 +12,10 @@ except Exception: # pragma: no cover class RedisCache: - def __init__(self): + def __init__(self, *, prefix_override: Optional[str] = None, default_ttl_override: Optional[int] = None): self.enabled = bool(REDIS_ENABLED and REDIS_URL and Redis is not None) - self.prefix = REDIS_PREFIX - self.default_ttl = int(REDIS_DEFAULT_TTL) + self.prefix = str(prefix_override or REDIS_PREFIX).strip() or REDIS_PREFIX + self.default_ttl = int(default_ttl_override if default_ttl_override is not None else REDIS_DEFAULT_TTL) self._client: Optional["Redis"] = None if self.enabled: try: @@ -34,11 +36,28 @@ class RedisCache: except Exception: return False + def get(self, key: str) -> Optional[str]: + if not self.enabled or self._client is None: + return None + try: + return self._client.get(self._full_key(key)) + except Exception: + return None + + def set(self, key: str, value: str, ttl: Optional[int] = None) -> None: + if not self.enabled or self._client is None: + return + try: + ttl_seconds = int(ttl if ttl is not None else self.default_ttl) + self._client.setex(self._full_key(key), ttl_seconds, str(value)) + except Exception: + return + def get_json(self, key: str) -> Any: if not self.enabled or self._client is None: return None try: - raw = self._client.get(self._full_key(key)) + raw = self.get(key) if not raw: return None return json.loads(raw) @@ -49,11 +68,46 @@ class RedisCache: if not self.enabled or self._client is None: return try: - self._client.setex( - self._full_key(key), - int(ttl if ttl is not None else self.default_ttl), - json.dumps(value, ensure_ascii=False, default=str), - ) + self.set(key, json.dumps(value, ensure_ascii=False, default=str), ttl=ttl) + except Exception: + return + + def sadd(self, key: str, *members: str) -> None: + if not self.enabled or self._client is None: + return + normalized = [str(member or "").strip() for member in members if str(member or "").strip()] + if not normalized: + return + try: + self._client.sadd(self._full_key(key), *normalized) + except Exception: + return + + def srem(self, key: str, *members: str) -> None: + if not self.enabled or self._client is None: + return + normalized = [str(member or "").strip() for member in members if str(member or "").strip()] + if not normalized: + return + try: + self._client.srem(self._full_key(key), *normalized) + except Exception: + return + + def smembers(self, key: str) -> set[str]: + if not self.enabled or self._client is None: + return set() + try: + rows = self._client.smembers(self._full_key(key)) + return {str(row or "").strip() for row in rows if str(row or "").strip()} + except Exception: + return set() + + def expire(self, key: str, ttl: int) -> None: + if not self.enabled or self._client is None: + return + try: + self._client.expire(self._full_key(key), max(1, int(ttl))) except Exception: return @@ -85,4 +139,4 @@ class RedisCache: cache = RedisCache() - +auth_cache = RedisCache(prefix_override=f"{REDIS_PREFIX}_auth") diff --git a/backend/core/database.py b/backend/core/database.py index bb978bd..fd2b6ef 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -11,6 +11,7 @@ from core.settings import ( ) # Ensure table models are registered in SQLModel metadata before create_all. +from models import auth as _auth_models # noqa: F401 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 @@ -32,6 +33,8 @@ BOT_MESSAGE_TABLE = "bot_message" BOT_IMAGE_TABLE = "bot_image" BOT_REQUEST_USAGE_TABLE = "bot_request_usage" BOT_ACTIVITY_EVENT_TABLE = "bot_activity_event" +SYS_LOGIN_LOG_TABLE = "sys_login_log" +LEGACY_AUTH_LOGIN_LOG_TABLE = "auth_login_log" SYS_SETTING_TABLE = "sys_setting" POSTGRES_MIGRATION_LOCK_KEY = 2026031801 @@ -58,6 +61,14 @@ def _release_migration_lock(lock_conn) -> None: lock_conn.close() +def _rename_table_if_needed(old_name: str, new_name: str) -> None: + inspector = inspect(engine) + if not inspector.has_table(old_name) or inspector.has_table(new_name): + return + with engine.connect() as conn: + conn.execute(text(f"ALTER TABLE {_quote_ident(old_name)} RENAME TO {_quote_ident(new_name)}")) + conn.commit() + def _ensure_botinstance_columns() -> None: required_columns = { "current_state": "TEXT DEFAULT 'IDLE'", @@ -133,6 +144,34 @@ def _ensure_bot_request_usage_columns() -> None: conn.commit() +def _migrate_auth_login_log_table() -> None: + _rename_table_if_needed(LEGACY_AUTH_LOGIN_LOG_TABLE, SYS_LOGIN_LOG_TABLE) + + +def _ensure_auth_login_log_columns() -> None: + required_columns = { + "auth_type": "TEXT NOT NULL DEFAULT 'bot'", + "token_hash": "TEXT", + "auth_source": "TEXT NOT NULL DEFAULT ''", + "revoke_reason": "TEXT", + "device_info": "TEXT", + } + inspector = inspect(engine) + if not inspector.has_table(SYS_LOGIN_LOG_TABLE): + return + with engine.connect() as conn: + existing = { + str(row.get("name")) + for row in inspect(conn).get_columns(SYS_LOGIN_LOG_TABLE) + if row.get("name") + } + for col, ddl in required_columns.items(): + if col in existing: + continue + conn.execute(text(f"ALTER TABLE {SYS_LOGIN_LOG_TABLE} ADD COLUMN {col} {ddl}")) + conn.commit() + + def _ensure_topic_columns() -> None: required_columns = { "topic_topic": { @@ -215,6 +254,7 @@ def align_postgres_sequences() -> None: if engine.dialect.name != "postgresql": return sequence_targets = [ + (SYS_LOGIN_LOG_TABLE, "id"), (BOT_MESSAGE_TABLE, "id"), (BOT_REQUEST_USAGE_TABLE, "id"), (BOT_ACTIVITY_EVENT_TABLE, "id"), @@ -247,7 +287,9 @@ def align_postgres_sequences() -> None: def init_database() -> None: lock_conn = _acquire_migration_lock() try: + _migrate_auth_login_log_table() SQLModel.metadata.create_all(engine) + _ensure_auth_login_log_columns() _ensure_sys_setting_columns() _ensure_bot_request_usage_columns() _ensure_botinstance_columns() diff --git a/backend/core/settings.py b/backend/core/settings.py index ca07171..daeb06c 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -76,6 +76,32 @@ def _env_extensions(name: str, default: tuple[str, ...]) -> tuple[str, ...]: return tuple(rows) +def _normalize_origin(raw: str) -> str: + text = str(raw or "").strip() + if not text: + return "" + try: + parsed = urlsplit(text) + except Exception: + return "" + scheme = str(parsed.scheme or "").strip().lower() + netloc = str(parsed.netloc or "").strip().lower() + if scheme not in {"http", "https"} or not netloc: + return "" + return urlunsplit((scheme, netloc, "", "", "")) + + +def _env_origins(name: str, default: tuple[str, ...]) -> tuple[str, ...]: + raw = os.getenv(name) + source = list(default) if raw is None else re.split(r"[,;\s]+", str(raw)) + rows: list[str] = [] + for item in source: + origin = _normalize_origin(item) + if origin and origin not in rows: + rows.append(origin) + return tuple(rows) + + def _normalize_dir_path(path_value: str) -> str: raw = str(path_value or "").strip() if not raw: @@ -158,6 +184,8 @@ DEFAULT_UPLOAD_MAX_MB: Final[int] = 100 DEFAULT_PAGE_SIZE: Final[int] = 10 DEFAULT_CHAT_PULL_PAGE_SIZE: Final[int] = 60 DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS: Final[int] = _env_int("COMMAND_AUTO_UNLOCK_SECONDS", 10, 1, 600) +DEFAULT_AUTH_TOKEN_TTL_HOURS: Final[int] = _env_int("AUTH_TOKEN_TTL_HOURS", 24, 1, 720) +DEFAULT_AUTH_TOKEN_MAX_ACTIVE: Final[int] = _env_int("AUTH_TOKEN_MAX_ACTIVE", 2, 1, 20) DEFAULT_BOT_SYSTEM_TIMEZONE: Final[str] = str( os.getenv("DEFAULT_BOT_SYSTEM_TIMEZONE") or os.getenv("TZ") or "Asia/Shanghai" ).strip() or "Asia/Shanghai" @@ -198,6 +226,15 @@ 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() +CORS_ALLOWED_ORIGINS: Final[tuple[str, ...]] = _env_origins( + "CORS_ALLOWED_ORIGINS", + ( + "http://localhost:5173", + "http://127.0.0.1:5173", + "http://localhost:4173", + "http://127.0.0.1:4173", + ), +) APP_HOST: Final[str] = str(os.getenv("APP_HOST") or "0.0.0.0").strip() APP_PORT: Final[int] = _env_int("APP_PORT", 8000, 1, 65535) diff --git a/backend/models/auth.py b/backend/models/auth.py new file mode 100644 index 0000000..73bd2b9 --- /dev/null +++ b/backend/models/auth.py @@ -0,0 +1,23 @@ +from datetime import datetime +from typing import Optional + +from sqlmodel import Field, SQLModel + + +class AuthLoginLog(SQLModel, table=True): + __tablename__ = "sys_login_log" + + id: Optional[int] = Field(default=None, primary_key=True) + auth_type: str = Field(index=True) # panel | bot + token_hash: str = Field(index=True, unique=True) + subject_id: str = Field(index=True) + bot_id: Optional[str] = Field(default=None, index=True) + auth_source: str = Field(default="", index=True) + created_at: datetime = Field(default_factory=datetime.utcnow, index=True) + expires_at: datetime = Field(index=True) + last_seen_at: datetime = Field(default_factory=datetime.utcnow, index=True) + revoked_at: Optional[datetime] = Field(default=None, index=True) + revoke_reason: Optional[str] = Field(default=None) + client_ip: Optional[str] = Field(default=None) + user_agent: Optional[str] = Field(default=None) + device_info: Optional[str] = Field(default=None) diff --git a/backend/schemas/platform.py b/backend/schemas/platform.py index 3f43d8c..9fb356b 100644 --- a/backend/schemas/platform.py +++ b/backend/schemas/platform.py @@ -7,6 +7,8 @@ class PlatformSettingsPayload(BaseModel): page_size: int = Field(default=10, ge=1, le=100) chat_pull_page_size: int = Field(default=60, ge=10, le=500) command_auto_unlock_seconds: int = Field(default=10, ge=1, le=600) + auth_token_ttl_hours: int = Field(default=24, ge=1, le=720) + auth_token_max_active: int = Field(default=2, ge=1, le=20) upload_max_mb: int = Field(default=100, ge=1, le=2048) allowed_attachment_extensions: List[str] = Field(default_factory=list) workspace_download_extensions: List[str] = Field(default_factory=list) @@ -63,6 +65,31 @@ class PlatformUsageResponse(BaseModel): analytics: PlatformUsageAnalytics +class PlatformLoginLogItem(BaseModel): + id: int + auth_type: str + subject_id: str + bot_id: Optional[str] = None + auth_source: str + client_ip: Optional[str] = None + user_agent: Optional[str] = None + device_info: Optional[str] = None + created_at: str + last_seen_at: Optional[str] = None + expires_at: Optional[str] = None + revoked_at: Optional[str] = None + revoke_reason: Optional[str] = None + status: str + + +class PlatformLoginLogResponse(BaseModel): + items: List[PlatformLoginLogItem] + total: int + limit: int + offset: int + has_more: bool + + class PlatformActivityItem(BaseModel): id: int bot_id: str diff --git a/backend/services/bot_runtime_service.py b/backend/services/bot_runtime_service.py new file mode 100644 index 0000000..5ab4a3d --- /dev/null +++ b/backend/services/bot_runtime_service.py @@ -0,0 +1,206 @@ +from __future__ import annotations + +import time +from datetime import datetime +from pathlib import Path +from typing import Any, Dict, Optional +from zoneinfo import ZoneInfo + +from fastapi import WebSocket +from sqlmodel import Session + +from core.docker_instance import docker_manager +from core.settings import BOTS_WORKSPACE_ROOT +from models.bot import BotInstance +from services.bot_channel_service import _get_bot_channels_from_config +from services.bot_lifecycle_service import start_bot_instance, stop_bot_instance +from services.bot_storage_service import _read_bot_config, _read_cron_store, _write_bot_config, _write_cron_store +from services.platform_auth_service import resolve_bot_websocket_auth, resolve_panel_websocket_auth + + +def _now_ms() -> int: + return int(time.time() * 1000) + + +def _get_bot_or_raise(session: Session, bot_id: str) -> BotInstance: + bot = session.get(BotInstance, bot_id) + if not bot: + raise LookupError("Bot not found") + return bot + + +def _weixin_state_file_path(bot_id: str) -> Path: + return Path(BOTS_WORKSPACE_ROOT) / bot_id / ".nanobot" / "weixin" / "account.json" + + +def _compute_cron_next_run(schedule: Dict[str, Any], now_ms: Optional[int] = None) -> Optional[int]: + current_ms = int(now_ms or _now_ms()) + kind = str(schedule.get("kind") or "").strip().lower() + + if kind == "at": + at_ms = int(schedule.get("atMs") or 0) + return at_ms if at_ms > current_ms else None + + if kind == "every": + every_ms = int(schedule.get("everyMs") or 0) + return current_ms + every_ms if every_ms > 0 else None + + if kind == "cron": + expr = str(schedule.get("expr") or "").strip() + if not expr: + return None + try: + from croniter import croniter + + tz_name = str(schedule.get("tz") or "").strip() + tz = ZoneInfo(tz_name) if tz_name else datetime.now().astimezone().tzinfo + base_dt = datetime.fromtimestamp(current_ms / 1000, tz=tz) + next_dt = croniter(expr, base_dt).get_next(datetime) + return int(next_dt.timestamp() * 1000) + except Exception: + return None + + return None + + +def get_bot_logs( + session: Session, + *, + bot_id: str, + tail: Optional[int] = 300, + offset: int = 0, + limit: Optional[int] = None, + reverse: bool = False, +) -> Dict[str, Any]: + _get_bot_or_raise(session, bot_id) + if limit is not None: + page = docker_manager.get_logs_page( + bot_id, + offset=max(0, int(offset)), + limit=max(1, int(limit)), + reverse=bool(reverse), + ) + return {"bot_id": bot_id, **page} + effective_tail = max(1, int(tail or 300)) + return {"bot_id": bot_id, "logs": docker_manager.get_recent_logs(bot_id, tail=effective_tail)} + + +async def relogin_weixin(session: Session, *, bot_id: str) -> Dict[str, Any]: + bot = _get_bot_or_raise(session, bot_id) + weixin_channel = next( + ( + row + for row in _get_bot_channels_from_config(bot) + if str(row.get("channel_type") or "").strip().lower() == "weixin" + ), + None, + ) + if not weixin_channel: + raise ValueError("Weixin channel not found") + + state_file = _weixin_state_file_path(bot_id) + removed = False + try: + if state_file.is_file(): + state_file.unlink() + removed = True + except Exception as exc: + raise RuntimeError(f"Failed to remove weixin state: {exc}") from exc + + config_data = _read_bot_config(bot_id) + channels_cfg = config_data.get("channels") if isinstance(config_data, dict) else {} + weixin_cfg = channels_cfg.get("weixin") if isinstance(channels_cfg, dict) else None + if isinstance(weixin_cfg, dict) and "token" in weixin_cfg: + weixin_cfg.pop("token", None) + _write_bot_config(bot_id, config_data) + + restarted = False + if str(bot.docker_status or "").upper() == "RUNNING": + stop_bot_instance(session, bot_id) + await start_bot_instance(session, bot_id) + restarted = True + + return { + "status": "relogin_started", + "bot_id": bot_id, + "removed_state": removed, + "restarted": restarted, + } + + +def list_cron_jobs(session: Session, *, bot_id: str, include_disabled: bool = True) -> Dict[str, Any]: + _get_bot_or_raise(session, bot_id) + 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 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 stop_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]: + _get_bot_or_raise(session, bot_id) + store = _read_cron_store(bot_id) + jobs = store.get("jobs", []) + if not isinstance(jobs, list): + jobs = [] + found = next((row for row in jobs if isinstance(row, dict) and str(row.get("id")) == job_id), None) + if not found: + raise LookupError("Cron job not found") + found["enabled"] = False + found["updatedAtMs"] = _now_ms() + state = found.get("state") + if not isinstance(state, dict): + state = {} + found["state"] = state + state["nextRunAtMs"] = None + _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs}) + return {"status": "stopped", "job_id": job_id} + + +def start_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]: + _get_bot_or_raise(session, bot_id) + store = _read_cron_store(bot_id) + jobs = store.get("jobs", []) + if not isinstance(jobs, list): + jobs = [] + found = next((row for row in jobs if isinstance(row, dict) and str(row.get("id")) == job_id), None) + if not found: + raise LookupError("Cron job not found") + found["enabled"] = True + found["updatedAtMs"] = _now_ms() + state = found.get("state") + if not isinstance(state, dict): + state = {} + found["state"] = state + schedule = found.get("schedule") + state["nextRunAtMs"] = _compute_cron_next_run(schedule if isinstance(schedule, dict) else {}) + _write_cron_store(bot_id, {"version": int(store.get("version", 1) or 1), "jobs": jobs}) + return {"status": "started", "job_id": job_id} + + +def delete_cron_job(session: Session, *, bot_id: str, job_id: str) -> Dict[str, Any]: + _get_bot_or_raise(session, bot_id) + 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 LookupError("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} + + +def ensure_monitor_websocket_access(session: Session, websocket: WebSocket, bot_id: str) -> BotInstance: + principal = resolve_panel_websocket_auth(session, websocket) + if not principal.authenticated: + principal = resolve_bot_websocket_auth(session, websocket, bot_id) + if not principal.authenticated: + raise PermissionError("Bot or panel authentication required") + return _get_bot_or_raise(session, bot_id) diff --git a/backend/services/platform_auth_service.py b/backend/services/platform_auth_service.py new file mode 100644 index 0000000..90699f5 --- /dev/null +++ b/backend/services/platform_auth_service.py @@ -0,0 +1,480 @@ +from __future__ import annotations + +import hashlib +import re +import secrets +from dataclasses import dataclass +from datetime import datetime, timedelta +from typing import Any, Mapping, Optional + +from fastapi import Request, Response, WebSocket +from sqlmodel import Session, select + +from core.cache import auth_cache +from core.settings import PANEL_ACCESS_PASSWORD +from models.auth import AuthLoginLog +from models.bot import BotInstance +from services.platform_settings_service import get_auth_token_max_active, get_auth_token_ttl_hours + +PANEL_TOKEN_COOKIE = "nanobot_panel_token" +BOT_TOKEN_COOKIE_PREFIX = "nanobot_bot_token_" +PANEL_SUBJECT_ID = "panel_admin" +AUTH_STORE_SET_TTL_BUFFER_SECONDS = 300 +SESSION_TOUCH_INTERVAL_SECONDS = 300 + + +@dataclass(frozen=True) +class AuthPrincipal: + auth_type: str + subject_id: str + bot_id: Optional[str] + authenticated: bool + auth_source: str + audit_id: Optional[int] = None + + +def _utcnow() -> datetime: + return datetime.utcnow() + + +def _normalize_token(raw: str) -> str: + return str(raw or "").strip() + + +def _hash_session_token(raw: str) -> str: + return hashlib.sha256(_normalize_token(raw).encode("utf-8")).hexdigest() + + +def _normalize_bot_cookie_name(bot_id: str) -> str: + safe_bot_id = re.sub(r"[^a-zA-Z0-9_-]+", "_", str(bot_id or "").strip()) + return f"{BOT_TOKEN_COOKIE_PREFIX}{safe_bot_id or 'bot'}" + + +def _token_key(token_hash: str) -> str: + return f"token:{str(token_hash or '').strip()}" + + +def _principal_tokens_key(auth_type: str, subject_id: str, bot_id: Optional[str] = None) -> str: + normalized_type = str(auth_type or "").strip().lower() or "unknown" + normalized_subject = re.sub(r"[^a-zA-Z0-9_.:-]+", "_", str(subject_id or "").strip() or "anonymous") + normalized_bot_id = re.sub(r"[^a-zA-Z0-9_.:-]+", "_", str(bot_id or "").strip()) if bot_id else "" + return f"principal:{normalized_type}:{normalized_subject}:{normalized_bot_id or '-'}" + + +def _auth_token_ttl_seconds(session: Session) -> int: + return max(1, int(get_auth_token_ttl_hours(session))) * 60 * 60 + + +def _auth_token_max_active(session: Session) -> int: + return max(1, int(get_auth_token_max_active(session))) + + +def _touch_session(session: Session, row: AuthLoginLog) -> None: + now = _utcnow() + last_seen = row.last_seen_at or row.created_at or now + if (now - last_seen).total_seconds() < SESSION_TOUCH_INTERVAL_SECONDS: + return + row.last_seen_at = now + session.add(row) + session.commit() + + +def _summarize_device(user_agent: str) -> str: + normalized = str(user_agent or "").strip().lower() + if not normalized: + return "Unknown Device" + + browser = "Unknown Browser" + if "edg/" in normalized: + browser = "Edge" + elif "chrome/" in normalized and "edg/" not in normalized: + browser = "Chrome" + elif "safari/" in normalized and "chrome/" not in normalized: + browser = "Safari" + elif "firefox/" in normalized: + browser = "Firefox" + + platform = "Desktop" + if "iphone" in normalized: + platform = "iPhone" + elif "ipad" in normalized: + platform = "iPad" + elif "android" in normalized: + platform = "Android" + elif "mac os x" in normalized or "macintosh" in normalized: + platform = "macOS" + elif "windows" in normalized: + platform = "Windows" + elif "linux" in normalized: + platform = "Linux" + + return f"{platform} / {browser}" + + +def _extract_client_ip(request: Request) -> str: + forwarded = str(request.headers.get("x-forwarded-for") or "").strip() + if forwarded: + return forwarded.split(",")[0].strip()[:120] + return str(getattr(request.client, "host", "") or "")[:120] + + +def _get_bearer_token(headers: Mapping[str, Any]) -> str: + authorization = str(headers.get("authorization") or headers.get("Authorization") or "").strip() + if not authorization.lower().startswith("bearer "): + return "" + return _normalize_token(authorization[7:]) + + +def _read_panel_token(request: Request) -> str: + cookie_token = _normalize_token(request.cookies.get(PANEL_TOKEN_COOKIE) or "") + return cookie_token or _get_bearer_token(request.headers) + + +def _read_bot_token(request: Request, bot_id: str) -> str: + cookie_token = _normalize_token(request.cookies.get(_normalize_bot_cookie_name(bot_id)) or "") + return cookie_token or _get_bearer_token(request.headers) + + +def _read_panel_token_ws(websocket: WebSocket) -> str: + cookie_token = _normalize_token(websocket.cookies.get(PANEL_TOKEN_COOKIE) or "") + return cookie_token or _get_bearer_token(websocket.headers) + + +def _read_bot_token_ws(websocket: WebSocket, bot_id: str) -> str: + cookie_token = _normalize_token(websocket.cookies.get(_normalize_bot_cookie_name(bot_id)) or "") + return cookie_token or _get_bearer_token(websocket.headers) + + +def _is_panel_auth_enabled() -> bool: + return bool(str(PANEL_ACCESS_PASSWORD or "").strip()) + + +def _get_bot_access_password(session: Session, bot_id: str) -> str: + bot = session.get(BotInstance, bot_id) + if not bot: + return "" + return str(bot.access_password or "").strip() + + +def _is_bot_access_enabled(session: Session, bot_id: str) -> bool: + return bool(_get_bot_access_password(session, bot_id)) + + +def _resolve_bot_auth_source(session: Session, bot_id: str) -> str: + return "bot_password" if _is_bot_access_enabled(session, bot_id) else "bot_public" + + +def _active_token_payload(token_hash: str) -> Optional[dict[str, Any]]: + payload = auth_cache.get_json(_token_key(token_hash)) + return payload if isinstance(payload, dict) else None + + +def _principal_from_payload(payload: dict[str, Any]) -> tuple[str, str, Optional[str]]: + auth_type = str(payload.get("auth_type") or "").strip().lower() + subject_id = str(payload.get("subject_id") or "").strip() + bot_id = str(payload.get("bot_id") or "").strip() or None + return auth_type, subject_id, bot_id + + +def _mark_audit_revoked(session: Session, token_hash: str, *, reason: str) -> None: + row = session.exec( + select(AuthLoginLog).where(AuthLoginLog.token_hash == token_hash).limit(1) + ).first() + if not row: + return + now = _utcnow() + row.last_seen_at = now + if row.revoked_at is None: + row.revoked_at = now + row.revoke_reason = str(reason or "").strip()[:120] or row.revoke_reason + session.add(row) + session.commit() + + +def _revoke_token_hash(session: Session, token_hash: str, *, reason: str) -> None: + normalized_hash = str(token_hash or "").strip() + if not normalized_hash: + return + payload = _active_token_payload(normalized_hash) + if payload: + auth_type, subject_id, bot_id = _principal_from_payload(payload) + auth_cache.delete(_token_key(normalized_hash)) + auth_cache.srem(_principal_tokens_key(auth_type, subject_id, bot_id), normalized_hash) + _mark_audit_revoked(session, normalized_hash, reason=reason) + + +def _revoke_raw_token(session: Session, raw_token: str, *, reason: str) -> None: + token = _normalize_token(raw_token) + if not token: + return + _revoke_token_hash(session, _hash_session_token(token), reason=reason) + + +def _cleanup_principal_set(session: Session, principal_key: str) -> list[tuple[int, str]]: + active_rows: list[tuple[int, str]] = [] + stale_hashes: list[str] = [] + for token_hash in auth_cache.smembers(principal_key): + payload = _active_token_payload(token_hash) + if not payload: + stale_hashes.append(token_hash) + continue + issued_at_ts = int(payload.get("issued_at_ts") or 0) + active_rows.append((issued_at_ts, token_hash)) + if stale_hashes: + auth_cache.srem(principal_key, *stale_hashes) + for stale_hash in stale_hashes: + _mark_audit_revoked(session, stale_hash, reason="expired") + return sorted(active_rows, key=lambda row: (row[0], row[1])) + + +def _ensure_auth_store_available() -> None: + if auth_cache.enabled: + return + raise RuntimeError("Redis authentication store is unavailable") + + +def _persist_token_payload( + session: Session, + *, + row: AuthLoginLog, + raw_token: str, + ttl_seconds: int, +) -> None: + token_hash = _hash_session_token(raw_token) + payload = { + "auth_type": row.auth_type, + "subject_id": row.subject_id, + "bot_id": row.bot_id, + "auth_source": row.auth_source, + "issued_at": row.created_at.isoformat() + "Z", + "issued_at_ts": int(row.created_at.timestamp()), + "expires_at": row.expires_at.isoformat() + "Z", + "audit_id": int(row.id or 0), + } + principal_key = _principal_tokens_key(row.auth_type, row.subject_id, row.bot_id) + auth_cache.set_json(_token_key(token_hash), payload, ttl=ttl_seconds) + auth_cache.sadd(principal_key, token_hash) + auth_cache.expire(principal_key, ttl_seconds + AUTH_STORE_SET_TTL_BUFFER_SECONDS) + if not _active_token_payload(token_hash): + row.revoked_at = _utcnow() + row.revoke_reason = "store_write_failed" + session.add(row) + session.commit() + raise RuntimeError("Failed to persist authentication token") + + +def _enforce_token_limit( + session: Session, + *, + auth_type: str, + subject_id: str, + bot_id: Optional[str], + max_active: int, +) -> None: + principal_key = _principal_tokens_key(auth_type, subject_id, bot_id) + rows = _cleanup_principal_set(session, principal_key) + overflow = max(0, len(rows) - max_active + 1) + if overflow <= 0: + return + for _, token_hash in rows[:overflow]: + _revoke_token_hash(session, token_hash, reason="concurrency_limit") + + +def _create_audit_row( + session: Session, + *, + request: Request, + auth_type: str, + subject_id: str, + bot_id: Optional[str], + raw_token: str, + expires_at: datetime, + auth_source: str, +) -> AuthLoginLog: + now = _utcnow() + row = AuthLoginLog( + auth_type=auth_type, + token_hash=_hash_session_token(raw_token), + subject_id=subject_id, + bot_id=bot_id, + auth_source=auth_source, + created_at=now, + expires_at=expires_at, + last_seen_at=now, + client_ip=_extract_client_ip(request), + user_agent=str(request.headers.get("user-agent") or "")[:500], + device_info=_summarize_device(str(request.headers.get("user-agent") or ""))[:255], + ) + session.add(row) + session.commit() + session.refresh(row) + return row + + +def _create_auth_token( + session: Session, + *, + request: Request, + auth_type: str, + subject_id: str, + bot_id: Optional[str], + auth_source: str, +) -> str: + _ensure_auth_store_available() + ttl_seconds = _auth_token_ttl_seconds(session) + max_active = _auth_token_max_active(session) + _enforce_token_limit( + session, + auth_type=auth_type, + subject_id=subject_id, + bot_id=bot_id, + max_active=max_active, + ) + raw_token = secrets.token_urlsafe(32) + row = _create_audit_row( + session, + request=request, + auth_type=auth_type, + subject_id=subject_id, + bot_id=bot_id, + raw_token=raw_token, + expires_at=_utcnow() + timedelta(seconds=ttl_seconds), + auth_source=auth_source, + ) + _persist_token_payload(session, row=row, raw_token=raw_token, ttl_seconds=ttl_seconds) + return raw_token + + +def create_panel_token(session: Session, request: Request) -> str: + revoke_panel_token(session, request, reason="superseded") + return _create_auth_token( + session, + request=request, + auth_type="panel", + subject_id=PANEL_SUBJECT_ID, + bot_id=None, + auth_source="panel_password", + ) + + +def create_bot_token(session: Session, request: Request, bot_id: str) -> str: + normalized_bot_id = str(bot_id or "").strip() + revoke_bot_token(session, request, normalized_bot_id, reason="superseded") + return _create_auth_token( + session, + request=request, + auth_type="bot", + subject_id=normalized_bot_id, + bot_id=normalized_bot_id, + auth_source=_resolve_bot_auth_source(session, normalized_bot_id), + ) + + +def revoke_panel_token(session: Session, request: Request, reason: str = "logout") -> None: + _revoke_raw_token(session, _read_panel_token(request), reason=reason) + + +def revoke_bot_token(session: Session, request: Request, bot_id: str, reason: str = "logout") -> None: + _revoke_raw_token(session, _read_bot_token(request, bot_id), reason=reason) + + +def _set_cookie(response: Response, request: Request, name: str, raw_token: str, max_age: int) -> None: + response.set_cookie( + name, + raw_token, + max_age=max_age, + httponly=True, + samesite="lax", + secure=str(request.url.scheme).lower() == "https", + path="/", + ) + + +def set_panel_token_cookie(response: Response, request: Request, raw_token: str, session: Session) -> None: + _set_cookie(response, request, PANEL_TOKEN_COOKIE, raw_token, _auth_token_ttl_seconds(session)) + + +def set_bot_token_cookie(response: Response, request: Request, bot_id: str, raw_token: str, session: Session) -> None: + _set_cookie(response, request, _normalize_bot_cookie_name(bot_id), raw_token, _auth_token_ttl_seconds(session)) + + +def clear_panel_token_cookie(response: Response) -> None: + response.delete_cookie(PANEL_TOKEN_COOKIE, path="/") + + +def clear_bot_token_cookie(response: Response, bot_id: str) -> None: + response.delete_cookie(_normalize_bot_cookie_name(bot_id), path="/") + + +def _resolve_token_auth( + session: Session, + *, + raw_token: str, + expected_type: str, + bot_id: Optional[str] = None, +) -> AuthPrincipal: + token = _normalize_token(raw_token) + normalized_bot_id = str(bot_id or "").strip() or None + if not token: + return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") + payload = _active_token_payload(_hash_session_token(token)) + if not payload: + return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") + auth_type, subject_id, payload_bot_id = _principal_from_payload(payload) + if auth_type != expected_type: + return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") + if expected_type == "bot" and payload_bot_id != normalized_bot_id: + return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") + + expires_at_raw = str(payload.get("expires_at") or "").strip() + if expires_at_raw: + try: + expires_at = datetime.fromisoformat(expires_at_raw.replace("Z", "")) + if expires_at <= _utcnow(): + _revoke_token_hash(session, _hash_session_token(token), reason="expired") + return AuthPrincipal(expected_type, "", normalized_bot_id, False, "missing") + except Exception: + pass + + row_id = int(payload.get("audit_id") or 0) or None + if row_id is not None: + row = session.get(AuthLoginLog, row_id) + if row is not None: + _touch_session(session, row) + return AuthPrincipal(expected_type, subject_id, payload_bot_id, True, f"{expected_type}_token", row_id) + + +def resolve_panel_request_auth(session: Session, request: Request) -> AuthPrincipal: + if not _is_panel_auth_enabled(): + return AuthPrincipal("panel", PANEL_SUBJECT_ID, None, True, "unprotected") + return _resolve_token_auth(session, raw_token=_read_panel_token(request), expected_type="panel") + + +def resolve_bot_request_auth(session: Session, request: Request, bot_id: str) -> AuthPrincipal: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return AuthPrincipal("bot", "", None, False, "missing") + return _resolve_token_auth( + session, + raw_token=_read_bot_token(request, normalized_bot_id), + expected_type="bot", + bot_id=normalized_bot_id, + ) + + +def resolve_panel_websocket_auth(session: Session, websocket: WebSocket) -> AuthPrincipal: + if not _is_panel_auth_enabled(): + return AuthPrincipal("panel", PANEL_SUBJECT_ID, None, True, "unprotected") + return _resolve_token_auth(session, raw_token=_read_panel_token_ws(websocket), expected_type="panel") + + +def resolve_bot_websocket_auth(session: Session, websocket: WebSocket, bot_id: str) -> AuthPrincipal: + normalized_bot_id = str(bot_id or "").strip() + if not normalized_bot_id: + return AuthPrincipal("bot", "", None, False, "missing") + return _resolve_token_auth( + session, + raw_token=_read_bot_token_ws(websocket, normalized_bot_id), + expected_type="bot", + bot_id=normalized_bot_id, + ) diff --git a/backend/services/platform_login_log_service.py b/backend/services/platform_login_log_service.py new file mode 100644 index 0000000..0fb4705 --- /dev/null +++ b/backend/services/platform_login_log_service.py @@ -0,0 +1,100 @@ +from __future__ import annotations + +from datetime import datetime +from typing import Optional + +from sqlalchemy import func, or_ +from sqlmodel import Session, select + +from models.auth import AuthLoginLog +from schemas.platform import PlatformLoginLogItem, PlatformLoginLogResponse + + +def _to_iso(value: Optional[datetime]) -> Optional[str]: + if value is None: + return None + return value.isoformat() + "Z" + + +def _normalize_status(value: str) -> str: + normalized = str(value or "").strip().lower() + if normalized in {"active", "revoked"}: + return normalized + return "all" + + +def list_login_logs( + session: Session, + *, + search: str = "", + auth_type: str = "", + status: str = "all", + limit: int = 50, + offset: int = 0, +) -> PlatformLoginLogResponse: + normalized_search = str(search or "").strip() + normalized_type = str(auth_type or "").strip().lower() + normalized_status = _normalize_status(status) + normalized_limit = max(1, min(200, int(limit or 50))) + normalized_offset = max(0, int(offset or 0)) + + stmt = select(AuthLoginLog) + count_stmt = select(func.count()).select_from(AuthLoginLog) + + if normalized_type in {"panel", "bot"}: + stmt = stmt.where(AuthLoginLog.auth_type == normalized_type) + count_stmt = count_stmt.where(AuthLoginLog.auth_type == normalized_type) + + if normalized_status == "active": + stmt = stmt.where(AuthLoginLog.revoked_at == None) # noqa: E711 + count_stmt = count_stmt.where(AuthLoginLog.revoked_at == None) # noqa: E711 + elif normalized_status == "revoked": + stmt = stmt.where(AuthLoginLog.revoked_at != None) # noqa: E711 + count_stmt = count_stmt.where(AuthLoginLog.revoked_at != None) # noqa: E711 + + if normalized_search: + like_value = f"%{normalized_search}%" + search_filter = or_( + AuthLoginLog.subject_id.ilike(like_value), + AuthLoginLog.bot_id.ilike(like_value), + AuthLoginLog.client_ip.ilike(like_value), + AuthLoginLog.device_info.ilike(like_value), + AuthLoginLog.user_agent.ilike(like_value), + AuthLoginLog.auth_source.ilike(like_value), + AuthLoginLog.revoke_reason.ilike(like_value), + ) + stmt = stmt.where(search_filter) + count_stmt = count_stmt.where(search_filter) + + total = int(session.exec(count_stmt).one() or 0) + rows = session.exec( + stmt.order_by(AuthLoginLog.created_at.desc(), AuthLoginLog.id.desc()).offset(normalized_offset).limit(normalized_limit) + ).all() + + items = [ + PlatformLoginLogItem( + id=int(row.id or 0), + auth_type=row.auth_type, + subject_id=row.subject_id, + bot_id=row.bot_id, + auth_source=str(row.auth_source or ""), + client_ip=row.client_ip, + user_agent=row.user_agent, + device_info=row.device_info, + created_at=_to_iso(row.created_at) or "", + last_seen_at=_to_iso(row.last_seen_at), + expires_at=_to_iso(row.expires_at), + revoked_at=_to_iso(row.revoked_at), + revoke_reason=row.revoke_reason, + status="revoked" if row.revoked_at else "active", + ) + for row in rows + ] + + return PlatformLoginLogResponse( + items=items, + total=total, + limit=normalized_limit, + offset=normalized_offset, + has_more=normalized_offset + len(items) < total, + ) diff --git a/backend/services/platform_runtime_settings_service.py b/backend/services/platform_runtime_settings_service.py index 322a863..03a4b0c 100644 --- a/backend/services/platform_runtime_settings_service.py +++ b/backend/services/platform_runtime_settings_service.py @@ -32,6 +32,8 @@ def default_platform_settings() -> 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"]), + auth_token_ttl_hours=int(bootstrap["auth_token_ttl_hours"]), + auth_token_max_active=int(bootstrap["auth_token_max_active"]), upload_max_mb=int(bootstrap["upload_max_mb"]), allowed_attachment_extensions=list(bootstrap["allowed_attachment_extensions"]), workspace_download_extensions=list(bootstrap["workspace_download_extensions"]), @@ -52,6 +54,14 @@ def get_platform_settings(session: Session) -> PlatformSettingsPayload: 1, min(600, int(data.get("command_auto_unlock_seconds") or merged["command_auto_unlock_seconds"])), ) + merged["auth_token_ttl_hours"] = max( + 1, + min(720, int(data.get("auth_token_ttl_hours") or merged["auth_token_ttl_hours"])), + ) + merged["auth_token_max_active"] = max( + 1, + min(20, int(data.get("auth_token_max_active") or merged["auth_token_max_active"])), + ) 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"]) @@ -68,6 +78,8 @@ def save_platform_settings(session: Session, payload: 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))), + auth_token_ttl_hours=max(1, min(720, int(payload.auth_token_ttl_hours))), + auth_token_max_active=max(1, min(20, int(payload.auth_token_max_active))), 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), @@ -116,6 +128,14 @@ def get_chat_pull_page_size() -> int: return get_platform_settings_snapshot().chat_pull_page_size +def get_auth_token_ttl_hours(session: Session) -> int: + return get_platform_settings(session).auth_token_ttl_hours + + +def get_auth_token_max_active(session: Session) -> int: + return get_platform_settings(session).auth_token_max_active + + def get_speech_runtime_settings() -> Dict[str, Any]: settings = get_platform_settings_snapshot() return { diff --git a/backend/services/platform_service.py b/backend/services/platform_service.py index 20e5faf..5a948ef 100644 --- a/backend/services/platform_service.py +++ b/backend/services/platform_service.py @@ -4,6 +4,7 @@ from services.platform_activity_service import ( prune_expired_activity_events, record_activity_event, ) +from services.platform_login_log_service import list_login_logs from services.platform_overview_service import build_platform_overview from services.platform_settings_service import ( ACTIVITY_EVENT_RETENTION_SETTING_KEY, @@ -16,6 +17,8 @@ from services.platform_settings_service import ( delete_system_setting, ensure_default_system_settings, get_activity_event_retention_days, + get_auth_token_max_active, + get_auth_token_ttl_hours, get_allowed_attachment_extensions, get_chat_pull_page_size, get_page_size, diff --git a/backend/services/platform_settings_core.py b/backend/services/platform_settings_core.py index 97d107b..fc9ffc0 100644 --- a/backend/services/platform_settings_core.py +++ b/backend/services/platform_settings_core.py @@ -7,6 +7,8 @@ from typing import Any, Dict, List from sqlmodel import Session from core.settings import ( + DEFAULT_AUTH_TOKEN_MAX_ACTIVE, + DEFAULT_AUTH_TOKEN_TTL_HOURS, DEFAULT_CHAT_PULL_PAGE_SIZE, DEFAULT_COMMAND_AUTO_UNLOCK_SECONDS, DEFAULT_PAGE_SIZE, @@ -24,6 +26,8 @@ SETTING_KEYS = ( "page_size", "chat_pull_page_size", "command_auto_unlock_seconds", + "auth_token_ttl_hours", + "auth_token_max_active", "upload_max_mb", "allowed_attachment_extensions", "workspace_download_extensions", @@ -38,6 +42,10 @@ DEPRECATED_SETTING_KEYS = { "speech_audio_preprocess", "speech_audio_filter", "speech_initial_prompt", + "sys_auth_token_ttl_days", + "auth_token_ttl_days", + "panel_session_ttl_days", + "bot_session_ttl_days", } SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "page_size": { @@ -67,6 +75,24 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "is_public": True, "sort_order": 9, }, + "auth_token_ttl_hours": { + "name": "认证 Token 过期小时数", + "category": "auth", + "description": "Panel 与 Bot 登录 Token 的统一有效时长,单位小时。", + "value_type": "integer", + "value": DEFAULT_AUTH_TOKEN_TTL_HOURS, + "is_public": False, + "sort_order": 10, + }, + "auth_token_max_active": { + "name": "认证 Token 最大并发数", + "category": "auth", + "description": "同一主体允许同时活跃的 Token 数量,超过时自动撤销最旧 Token。", + "value_type": "integer", + "value": DEFAULT_AUTH_TOKEN_MAX_ACTIVE, + "is_public": False, + "sort_order": 11, + }, "upload_max_mb": { "name": "上传大小限制", "category": "upload", @@ -74,7 +100,7 @@ SYSTEM_SETTING_DEFINITIONS: Dict[str, Dict[str, Any]] = { "value_type": "integer", "value": DEFAULT_UPLOAD_MAX_MB, "is_public": False, - "sort_order": 10, + "sort_order": 20, }, "allowed_attachment_extensions": { "name": "允许附件后缀", @@ -197,6 +223,18 @@ def _bootstrap_platform_setting_values() -> Dict[str, Any]: 1, 600, ), + "auth_token_ttl_hours": _legacy_env_int( + "AUTH_TOKEN_TTL_HOURS", + DEFAULT_AUTH_TOKEN_TTL_HOURS, + 1, + 720, + ), + "auth_token_max_active": _legacy_env_int( + "AUTH_TOKEN_MAX_ACTIVE", + DEFAULT_AUTH_TOKEN_MAX_ACTIVE, + 1, + 20, + ), "upload_max_mb": _legacy_env_int("UPLOAD_MAX_MB", DEFAULT_UPLOAD_MAX_MB, 1, 2048), "allowed_attachment_extensions": _legacy_env_extensions( "ALLOWED_ATTACHMENT_EXTENSIONS", diff --git a/backend/services/platform_settings_service.py b/backend/services/platform_settings_service.py index 06587b9..6b2cbc9 100644 --- a/backend/services/platform_settings_service.py +++ b/backend/services/platform_settings_service.py @@ -1,4 +1,6 @@ from services.platform_runtime_settings_service import ( + get_auth_token_max_active, + get_auth_token_ttl_hours, default_platform_settings, get_allowed_attachment_extensions, get_chat_pull_page_size, diff --git a/backend/services/platform_system_settings_service.py b/backend/services/platform_system_settings_service.py index 2515e32..039c34f 100644 --- a/backend/services/platform_system_settings_service.py +++ b/backend/services/platform_system_settings_service.py @@ -1,3 +1,4 @@ +import json from typing import Any, Dict, List from sqlmodel import Session, select @@ -21,6 +22,14 @@ from services.platform_settings_core import ( ) +def _coerce_auth_ttl_hours_from_legacy(value: Any) -> int: + try: + normalized = int(value) + except Exception: + normalized = 0 + return max(1, min(720, normalized * 24)) + + def ensure_default_system_settings(session: Session) -> None: bootstrap_values = _bootstrap_platform_setting_values() legacy_row = session.get(PlatformSetting, "global") @@ -46,15 +55,24 @@ def ensure_default_system_settings(session: Session) -> None: session.delete(legacy_row) session.commit() + legacy_auth_ttl_hours = None dirty = False for key in DEPRECATED_SETTING_KEYS: legacy_row = session.get(PlatformSetting, key) if legacy_row is not None: + if key in {"sys_auth_token_ttl_days", "auth_token_ttl_days"} and legacy_auth_ttl_hours is None: + try: + legacy_auth_ttl_hours = _coerce_auth_ttl_hours_from_legacy(_read_setting_value(legacy_row)) + except Exception: + legacy_auth_ttl_hours = None session.delete(legacy_row) dirty = True for key, meta in SYSTEM_SETTING_DEFINITIONS.items(): row = session.get(PlatformSetting, key) + default_value = bootstrap_values.get(key, meta["value"]) + if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None: + default_value = legacy_auth_ttl_hours if row is None: _upsert_setting_row( session, @@ -63,22 +81,42 @@ def ensure_default_system_settings(session: Session) -> None: category=str(meta["category"]), description=str(meta["description"]), value_type=str(meta["value_type"]), - value=bootstrap_values.get(key, meta["value"]), + value=default_value, is_public=bool(meta["is_public"]), sort_order=int(meta["sort_order"]), ) dirty = True continue changed = False + if key == "auth_token_ttl_hours" and legacy_auth_ttl_hours is not None: + try: + current_value = int(_read_setting_value(row)) + except Exception: + current_value = int(meta["value"]) + if current_value == int(meta["value"]) and legacy_auth_ttl_hours != current_value: + row.value_type = str(meta["value_type"]) + row.value_json = json.dumps(legacy_auth_ttl_hours, ensure_ascii=False) + changed = True for field in ("name", "category", "description", "value_type"): value = str(meta[field]) - if not getattr(row, field): + if key in PROTECTED_SETTING_KEYS: + if getattr(row, field) != value: + setattr(row, field, value) + changed = True + elif not getattr(row, field): setattr(row, field, value) changed = True - if getattr(row, "sort_order", None) is None: + if key in PROTECTED_SETTING_KEYS: + if int(getattr(row, "sort_order", 100) or 100) != int(meta["sort_order"]): + row.sort_order = int(meta["sort_order"]) + changed = True + if bool(getattr(row, "is_public", False)) != bool(meta["is_public"]): + row.is_public = bool(meta["is_public"]) + changed = True + elif getattr(row, "sort_order", None) is None: row.sort_order = int(meta["sort_order"]) changed = True - if getattr(row, "is_public", None) is None: + if key not in PROTECTED_SETTING_KEYS and getattr(row, "is_public", None) is None: row.is_public = bool(meta["is_public"]) changed = True if changed: diff --git a/design/code-structure-standards.md b/design/code-structure-standards.md index 5472953..64cd687 100644 --- a/design/code-structure-standards.md +++ b/design/code-structure-standards.md @@ -21,7 +21,14 @@ - 不允许为了“看起来模块化”而把强耦合逻辑拆成大量碎文件。 - 允许保留中等体量的“单主题控制器”文件,但不允许继续把多个主题堆进一个文件。 -### 1.2 低风险重构优先 +### 1.2 领域内聚优先于机械拆分 + +- 代码拆分的第一判断标准是“是否仍属于同一业务域”,不是“是否还能再拆小”。 +- 同一业务域内的读、写、校验、少量编排、少量派生逻辑,可以保留在同一个模块中。 +- 如果拆分只会制造多层跳转、隐藏真实依赖、降低可读性,则不应继续拆。 +- 真正需要拆分的场景是跨域、跨层、跨边界,而不是单纯文件偏长。 + +### 1.3 低风险重构优先 - 结构重构优先做“搬运与收口”,不顺手修改业务行为。 - 同一轮改动里,默认**不要**同时做: @@ -30,13 +37,13 @@ - 行为修复 - 如果确实需要行为修复,只允许修复拆分直接引入的问题。 -### 1.3 装配层必须薄 +### 1.4 装配层必须薄 - 页面层、路由层、应用启动层都只负责装配。 - 装配层可以做依赖注入、状态接线、事件转发。 - 装配层不允许承载复杂业务判断、持久化细节、长流程编排。 -### 1.4 新文件必须按主题命名 +### 1.5 新文件必须按主题命名 - 文件名必须直接表达职责。 - 禁止模糊命名,例如: @@ -70,6 +77,12 @@ - `frontend/src/utils` - 真正跨领域的通用工具 +目录分层的目标是稳定边界,不是把每一段逻辑都拆成独立文件: + +- 同一页面域内强关联的视图、状态、交互逻辑,允许在同一模块内靠近放置 +- 只有当某段逻辑已经被多个页面或多个子流程稳定复用时,才提炼到更高层级 +- 禁止为了“文件更短”而把一个连续可读的页面流程拆成大量来回跳转的小文件 + ### 2.2 页面文件职责 页面文件如: @@ -83,12 +96,14 @@ - 只做页面装配 - 只组织已有区块、弹层、控制器 hook - 不直接承载长段 API 请求、副作用、数据清洗逻辑 +- 如果一个页面本身就是单一业务域,并且逻辑连续可读,可以保留适量页面内状态与事件处理 +- 不要求为了行数把本来紧密耦合的页面逻辑强拆到多个 hooks / sections / shared 文件中 页面文件目标体量: -- 目标:`< 500` 行 -- 可接受上限:`800` 行 -- 超过 `800` 行必须优先拆出页面控制器 hook 或区块装配组件 +- 行数只作为预警,不作为硬性拆分依据 +- 先判断页面是否仍然属于单一业务域、是否能顺序读懂、依赖是否清晰 +- 只有在页面同时承担多个子域、多个弹层流程、多个数据源编排时,才优先拆出页面控制器 hook 或区块装配组件 ### 2.3 控制器 hook 规范 @@ -111,12 +126,15 @@ - 一个 hook 只服务一个明确页面或一个明确子流程 - hook 不直接产出大量 JSX - hook 内部允许组合更小的子 hook,但不要为了拆分而拆分 +- 如果页面逻辑并不复杂,不要求必须抽出“页面总 hook” +- 只有当副作用编排、状态联动、接口交互已经影响页面可读性时,才值得抽成控制器 hook 控制器 hook 目标体量: -- 目标:`< 800` 行 -- 可接受上限:`1000` 行 -- 超过 `1000` 行时,必须再按主题拆成子 hook 或把重复逻辑提到 `shared`/`api` +- 行数只作为风险提示 +- 优先保证 hook 的流程连续、命名清晰、状态收口明确 +- 如果继续拆分只会让调用链更深、上下文更难追踪,则不应继续拆 +- 只有当 hook 明显同时承载多个子流程时,才按主题拆成子 hook 或把稳定复用逻辑提到 `shared`/`api` ### 2.4 视图组件规范 @@ -130,6 +148,7 @@ - 视图组件默认不直接请求接口 - 视图组件只接收已经整理好的 props - 纯视图组件内部不保留与页面强耦合的业务缓存 +- 不要求把所有小片段都抽成组件;只在存在明确复用、明显视觉区块、或能显著降低页面噪音时再拆组件 ### 2.5 前端复用原则 @@ -137,6 +156,8 @@ - 三处以上重复,优先考虑抽取 - 同域复用优先放 `modules//shared` - 跨域复用优先放 `src/components` 或 `src/utils` +- 如果抽取后的接口比原地实现更难理解,就不应抽取 +- 不允许创建只有单个页面使用、但又被过度包装的“伪复用层” ### 2.6 前端禁止事项 @@ -144,6 +165,8 @@ - 禁止把样式、业务逻辑、视图结构三者重新耦合回单文件 - 禁止创建无明确职责的超通用组件 - 禁止为减少行数而做不可读的过度抽象 +- 禁止为了满足结构指标,把单一页面域强拆成大量细碎 hooks、sections、shared 文件 +- 禁止新增纯转发、纯包装、无独立语义价值的组件或 hook --- @@ -211,7 +234,7 @@ Router 文件体量规则: ### 3.4 Service 规范 -Service 必须按业务主题拆分。 +Service 必须按业务域内聚组织,而不是为了压缩行数而机械切碎。 允许的 service 类型: @@ -226,15 +249,18 @@ Service 必须按业务主题拆分。 Service 文件规则: -- 一个文件只负责一个主题 -- 同一文件内允许有私有 helper,但 helper 只能服务当前主题 -- 如果一个主题明显包含“读模型 + 写模型 + 统计 + 配置”,应继续拆为多个 service +- 一个文件只负责一个业务域或一个稳定子主题 +- 同一文件内允许同时包含该域内的查询、写入、校验、少量派生逻辑 +- 同一文件内允许有私有 helper,但 helper 只能服务当前域 +- 只有当一个文件已经明显跨域,或者把 router/core/provider 的职责卷入进来时,才必须继续拆分 +- 不允许为了“看起来更模块化”而创建纯转发、纯 re-export、纯别名性质的 service 层 Service 体量规则: -- 目标:`< 350` 行 -- 可接受上限:`500` 行 -- 超过 `500` 行必须继续拆 +- 行数只作为预警信号,不作为机械拆分依据 +- 优先判断是否仍然保持单一业务域、可顺序阅读、依赖方向清晰 +- 如果一个文件虽然较大,但域边界稳定、跳转成本低、上下文连续,可以保留 +- 如果一个文件即使不大,但已经跨域、跨层、混入无关职责,也必须拆分 ### 3.6 Schema 规范 @@ -290,6 +316,7 @@ Service 体量规则: - 禁止回到“大文件集中堆功能”的开发方式 - 禁止为了图省事把新逻辑加回兼容层 - 禁止在没有明确复用收益时过度抽象 +- 禁止为了满足行数指标而把同一业务域强行拆碎 - 禁止在一次改动里同时重写 UI、重写数据流、重写接口协议 --- diff --git a/frontend/.env.example b/frontend/.env.example index f6dddf3..fc6d436 100644 --- a/frontend/.env.example +++ b/frontend/.env.example @@ -1,5 +1,5 @@ # Backend API entry -VITE_API_BASE=http://localhost:8000/api +VITE_API_BASE=/api # Backend WebSocket entry -VITE_WS_BASE=ws://localhost:8000/ws/monitor +VITE_WS_BASE=/ws/monitor diff --git a/frontend/src/App.css b/frontend/src/App.css index a30f8a3..d1d0046 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -1075,3 +1075,23 @@ body { max-height: 84vh; overflow-y: auto; } + +.route-loading-shell { + min-height: 320px; + display: flex; + align-items: center; + justify-content: center; +} + +.route-loading-card { + min-width: min(320px, calc(100vw - 48px)); + padding: 18px 20px; + border: 1px solid color-mix(in oklab, var(--line) 80%, transparent); + border-radius: 16px; + background: color-mix(in oklab, var(--panel) 88%, transparent); + color: var(--subtitle); + box-shadow: var(--shadow); + text-align: center; + font-weight: 600; + letter-spacing: 0.01em; +} diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index d4220ac..4b66c61 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,34 +1,40 @@ -import { useEffect, useState, type ReactElement } from 'react'; -import axios from 'axios'; -import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, SunMedium, X } from 'lucide-react'; +import { Suspense, lazy, useEffect, useState } from 'react'; +import { Activity, Bot, Boxes, FileText, Hammer, LayoutDashboard, Menu, MessageSquareText, MoonStar, Settings2, ShieldCheck, SunMedium, X } from 'lucide-react'; -import { PasswordInput } from './components/PasswordInput'; +import { BotRouteAccessGate } from './app/BotRouteAccessGate'; +import { PanelLoginGate } from './app/PanelLoginGate'; import { LucentTooltip } from './components/lucent/LucentTooltip'; -import { APP_ENDPOINTS } from './config/env'; import { useBotsSync } from './hooks/useBotsSync'; import { appEn } from './i18n/app.en'; import { appZhCn } from './i18n/app.zh-cn'; import { pickLocale } from './i18n'; -import { BotHomePage } from './modules/bot-home/BotHomePage'; -import { PlatformAdminDashboardPage } from './modules/platform/PlatformAdminDashboardPage'; -import { PlatformBotManagementPage } from './modules/platform/PlatformBotManagementPage'; -import { PlatformImageManagementPage } from './modules/platform/PlatformImageManagementPage'; -import { PlatformSettingsPage } from './modules/platform/components/PlatformSettingsModal'; -import { SkillMarketManagerPage } from './modules/platform/components/SkillMarketManagerModal'; -import { TemplateManagerPage } from './modules/platform/components/TemplateManagerModal'; import { useAppStore } from './store/appStore'; -import { clearBotAccessPassword, getBotAccessPassword, setBotAccessPassword } from './utils/botAccess'; -import { clearPanelAccessPassword, getPanelAccessPassword, setPanelAccessPassword } from './utils/panelAccess'; import { getAppRouteMeta, navigateToRoute, readCompactModeFromUrl, useAppRoute, type AppRoute } from './utils/appRoute'; import './components/ui/SharedUi.css'; import './App.css'; import './App.h5.css'; const defaultLoadingTitle = 'Dashboard Nanobot'; +const LazyBotHomePage = lazy(() => import('./modules/bot-home/BotHomePage').then((module) => ({ default: module.BotHomePage }))); +const LazyPlatformAdminDashboardPage = lazy(() => import('./modules/platform/PlatformAdminDashboardPage').then((module) => ({ default: module.PlatformAdminDashboardPage }))); +const LazyPlatformBotManagementPage = lazy(() => import('./modules/platform/PlatformBotManagementPage').then((module) => ({ default: module.PlatformBotManagementPage }))); +const LazyPlatformImageManagementPage = lazy(() => import('./modules/platform/PlatformImageManagementPage').then((module) => ({ default: module.PlatformImageManagementPage }))); +const LazyPlatformLoginLogPage = lazy(() => import('./modules/platform/PlatformLoginLogPage').then((module) => ({ default: module.PlatformLoginLogPage }))); +const LazyPlatformSettingsPage = lazy(() => import('./modules/platform/components/PlatformSettingsPage').then((module) => ({ default: module.PlatformSettingsPage }))); +const LazySkillMarketManagerPage = lazy(() => import('./modules/platform/components/SkillMarketManagerPage').then((module) => ({ default: module.SkillMarketManagerPage }))); +const LazyTemplateManagerPage = lazy(() => import('./modules/platform/components/TemplateManagerPage').then((module) => ({ default: module.TemplateManagerPage }))); type CompactBotPanelTab = 'chat' | 'runtime'; -function AuthenticatedApp() { +function RouteLoadingFallback({ label }: { label: string }) { + return ( +
+
{label}
+
+ ); +} + +function AppShell() { const route = useAppRoute(); const { theme, setTheme, locale, setLocale, activeBots } = useAppStore(); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); @@ -40,13 +46,6 @@ function AuthenticatedApp() { const [appNavDrawerOpen, setAppNavDrawerOpen] = useState(false); const [botPanelDrawerOpen, setBotPanelDrawerOpen] = useState(false); const [botCompactPanelTab, setBotCompactPanelTab] = useState('chat'); - 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 : ''; useBotsSync(forcedBotId || undefined); @@ -65,9 +64,6 @@ function AuthenticatedApp() { const forcedBotName = String(forcedBot?.name || '').trim(); const forcedBotIdLabel = String(forcedBotId || '').trim(); const botDocumentTitle = [forcedBotName, forcedBotIdLabel].filter(Boolean).join(' ') || defaultLoadingTitle; - const shouldPromptSingleBotPassword = Boolean( - route.kind === 'bot' && forcedBotId && forcedBot?.has_access_password && !singleBotUnlocked, - ); const routeMeta = getAppRouteMeta(route, { isZh, botName: forcedBotName || undefined }); const showNavRail = route.kind !== 'bot' && !compactMode; const showAppNavDrawerEntry = route.kind !== 'bot' && compactMode; @@ -81,12 +77,6 @@ function AuthenticatedApp() { document.title = `${t.title} - ${route.kind === 'bot' ? botDocumentTitle : routeMeta.title}`; }, [botDocumentTitle, route.kind, routeMeta.title, t.title]); - useEffect(() => { - setSingleBotUnlocked(false); - setSingleBotPassword(''); - setSingleBotPasswordError(''); - }, [forcedBotId]); - useEffect(() => { if (!showBotPanelDrawerEntry) { setBotPanelDrawerOpen(false); @@ -98,52 +88,6 @@ function AuthenticatedApp() { if (!showAppNavDrawerEntry) setAppNavDrawerOpen(false); }, [route.kind, showAppNavDrawerEntry]); - useEffect(() => { - if (route.kind !== 'bot' || !forcedBotId || !forcedBot?.has_access_password || singleBotUnlocked) return; - const stored = getBotAccessPassword(forcedBotId); - if (!stored) return; - let alive = true; - const boot = async () => { - try { - await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(forcedBotId)}/auth/login`, { password: stored }); - if (!alive) return; - setBotAccessPassword(forcedBotId, stored); - setSingleBotUnlocked(true); - setSingleBotPassword(''); - setSingleBotPasswordError(''); - } catch { - clearBotAccessPassword(forcedBotId); - if (!alive) return; - setSingleBotPasswordError(locale === 'zh' ? 'Bot 密码错误,请重新输入。' : 'Invalid bot password. Please try again.'); - } - }; - void boot(); - return () => { - alive = false; - }; - }, [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 botPanelLabels = t.botPanels; const drawerBotName = String(forcedBot?.name || '').trim() || defaultLoadingTitle; const drawerBotId = String(forcedBotId || '').trim() || '-'; @@ -171,6 +115,7 @@ function AuthenticatedApp() { label: 'System', items: [ { kind: 'system-skills', label: isZh ? '技能市场' : 'Skill Marketplace', icon: Hammer }, + { kind: 'system-login-logs', label: isZh ? '登录日志' : 'Login Logs', icon: ShieldCheck }, { kind: 'system-templates', label: isZh ? '模版管理' : 'Template Management', icon: FileText }, { kind: 'system-settings', label: isZh ? '参数管理' : 'Parameter Management', icon: Settings2 }, { kind: 'system-images', label: isZh ? '镜像管理' : 'Image Management', icon: Boxes }, @@ -188,28 +133,32 @@ function AuthenticatedApp() { const renderRoutePage = () => { switch (route.kind) { case 'admin-dashboard': - return ; + return ; case 'admin-bots': - return ; + return ; case 'system-skills': - return ; + return ; + case 'system-login-logs': + return ; case 'system-templates': - return ; + return ; case 'system-settings': - return ; + return ; case 'system-images': - return ; + return ; case 'bot': return ( - + + + ); default: - return ; + return ; } }; @@ -343,7 +292,9 @@ function AuthenticatedApp() {
- {renderRoutePage()} + }> + {renderRoutePage()} +
@@ -458,176 +409,15 @@ function AuthenticatedApp() { ) : null} - {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; - clearPanelAccessPassword(); - setRequired(true); - setAuthenticated(false); - setError(locale === 'zh' ? '无法校验面板访问状态,请检查后端连接后重试。' : 'Unable to verify panel access. Check the backend connection and try again.'); - setChecking(false); - } - }; - void boot(); - return () => { - alive = false; - }; - }, [bypassPanelGate, locale]); - - const onSubmit = async () => { - const next = String(password || '').trim(); - if (!next) { - setError(locale === 'zh' ? '请输入面板访问密码。' : 'Enter the panel access 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.'); - } finally { - setSubmitting(false); - } - }; - - if (checking) { - return ( -
-
-
- Nanobot -

{t.title}

-

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

-
-
-
- ); - } - - if (required && !authenticated) { - 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} - -
-
-
-
- ); - } - - return children; -} - function App() { + const route = useAppRoute(); return ( - - + + ); } diff --git a/frontend/src/app/BotRouteAccessGate.tsx b/frontend/src/app/BotRouteAccessGate.tsx new file mode 100644 index 0000000..dd382ab --- /dev/null +++ b/frontend/src/app/BotRouteAccessGate.tsx @@ -0,0 +1,222 @@ +import { useCallback, useEffect, useState, type ReactNode } from 'react'; +import axios from 'axios'; + +import { PasswordInput } from '../components/PasswordInput'; +import { APP_ENDPOINTS } from '../config/env'; +import { useAppStore } from '../store/appStore'; +import type { BotState } from '../types/bot'; +import { + BOT_AUTH_INVALID_EVENT, + clearBotAccessPassword, + getBotAccessPassword, + setBotAccessPassword, +} from '../utils/botAccess'; + +interface BotRouteAccessGateProps { + bot?: BotState; + botId: string; + children: ReactNode; +} + +const BOT_PASSWORD_LABELS = { + zh: { + checking: '校验中...', + continue: '进入', + errorEmpty: '请输入 Bot 密码。', + errorExpired: '登录已失效,请重新输入 Bot 密码。', + errorInvalid: 'Bot 密码错误,请重试。', + prompt: '请输入该 Bot 的访问密码后继续。', + placeholder: 'Bot 密码', + }, + en: { + checking: 'Checking...', + continue: 'Continue', + errorEmpty: 'Enter the bot password.', + errorExpired: 'Your login expired. Enter the bot password again.', + errorInvalid: 'Invalid bot password. Please try again.', + prompt: 'Enter the bot password to continue.', + placeholder: 'Bot password', + }, +} as const; + +export function BotRouteAccessGate({ + bot, + botId, + children, +}: BotRouteAccessGateProps) { + const { locale, setBots } = useAppStore(); + const isZh = locale === 'zh'; + const copy = isZh ? BOT_PASSWORD_LABELS.zh : BOT_PASSWORD_LABELS.en; + const passwordToggleLabels = isZh + ? { show: '显示密码', hide: '隐藏密码' } + : { show: 'Show password', hide: 'Hide password' }; + const normalizedBotId = String(botId || '').trim(); + const [password, setPassword] = useState(''); + const [passwordError, setPasswordError] = useState(''); + const [passwordEnabled, setPasswordEnabled] = useState(false); + const [authChecking, setAuthChecking] = useState(Boolean(normalizedBotId)); + const [unlocked, setUnlocked] = useState(false); + const [submitting, setSubmitting] = useState(false); + const [authRefreshNonce, setAuthRefreshNonce] = useState(0); + + const refreshBotDetail = useCallback(async () => { + if (!normalizedBotId) return; + const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}`); + setBots(res.data ? [res.data] : []); + }, [normalizedBotId, setBots]); + + useEffect(() => { + setPasswordEnabled(false); + setAuthChecking(Boolean(normalizedBotId)); + setUnlocked(false); + setPassword(''); + setPasswordError(''); + }, [normalizedBotId]); + + useEffect(() => { + if (!normalizedBotId) { + setAuthChecking(false); + return; + } + let alive = true; + const bootstrapBotAuth = async () => { + setAuthChecking(true); + try { + const status = await axios.get<{ + enabled?: boolean; + authenticated?: boolean; + }>(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/status`); + if (!alive) return; + const enabled = Boolean(status.data?.enabled); + const authenticated = Boolean(status.data?.authenticated); + setPasswordEnabled(enabled); + if (!enabled && !authenticated) { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: '' }); + if (!alive) return; + setUnlocked(true); + setPassword(''); + setPasswordError(''); + await refreshBotDetail(); + return; + } + setUnlocked(authenticated); + if (authenticated) { + await refreshBotDetail(); + } + } catch { + if (!alive) return; + setPasswordEnabled(false); + setUnlocked(false); + } finally { + if (alive) setAuthChecking(false); + } + }; + void bootstrapBotAuth(); + return () => { + alive = false; + }; + }, [authRefreshNonce, normalizedBotId, refreshBotDetail]); + + useEffect(() => { + if (typeof window === 'undefined' || !normalizedBotId) return undefined; + const handleBotAuthInvalid = (event: Event) => { + const customEvent = event as CustomEvent<{ botId?: string }>; + const invalidBotId = String(customEvent.detail?.botId || '').trim(); + if (!invalidBotId || invalidBotId !== normalizedBotId) return; + setUnlocked(false); + setAuthRefreshNonce((value) => value + 1); + setPassword(''); + setPasswordError(passwordEnabled ? copy.errorExpired : ''); + setAuthChecking(true); + }; + window.addEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener); + return () => window.removeEventListener(BOT_AUTH_INVALID_EVENT, handleBotAuthInvalid as EventListener); + }, [copy.errorExpired, normalizedBotId, passwordEnabled]); + + useEffect(() => { + if (!normalizedBotId || !passwordEnabled || unlocked) return; + const stored = getBotAccessPassword(normalizedBotId); + if (!stored) return; + let alive = true; + const boot = async () => { + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: stored }); + if (!alive) return; + setBotAccessPassword(normalizedBotId, stored); + setUnlocked(true); + setPassword(''); + setPasswordError(''); + await refreshBotDetail(); + } catch { + clearBotAccessPassword(normalizedBotId); + if (!alive) return; + setPasswordError(copy.errorInvalid); + } + }; + void boot(); + return () => { + alive = false; + }; + }, [copy.errorInvalid, normalizedBotId, passwordEnabled, refreshBotDetail, unlocked]); + + const unlockBot = async () => { + const entered = String(password || '').trim(); + if (!entered || !normalizedBotId) { + setPasswordError(copy.errorEmpty); + return; + } + setSubmitting(true); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${encodeURIComponent(normalizedBotId)}/auth/login`, { password: entered }); + setBotAccessPassword(normalizedBotId, entered); + setPasswordError(''); + setUnlocked(true); + setPassword(''); + await refreshBotDetail(); + } catch { + clearBotAccessPassword(normalizedBotId); + setPasswordError(copy.errorInvalid); + } finally { + setSubmitting(false); + } + }; + + const shouldPromptPassword = Boolean( + normalizedBotId && passwordEnabled && !authChecking && !unlocked, + ); + + return ( + <> + {children} + {shouldPromptPassword ? ( +
+
event.stopPropagation()}> + Nanobot +

{bot?.name || normalizedBotId}

+

{copy.prompt}

+
+ { + setPassword(event.target.value); + if (passwordError) setPasswordError(''); + }} + onKeyDown={(event) => { + if (event.key === 'Enter') void unlockBot(); + }} + placeholder={copy.placeholder} + autoFocus + toggleLabels={passwordToggleLabels} + /> + {passwordError ?
{passwordError}
: null} + +
+
+
+ ) : null} + + ); +} diff --git a/frontend/src/app/PanelLoginGate.tsx b/frontend/src/app/PanelLoginGate.tsx new file mode 100644 index 0000000..0af6213 --- /dev/null +++ b/frontend/src/app/PanelLoginGate.tsx @@ -0,0 +1,179 @@ +import { useEffect, useState, type ReactElement } from 'react'; +import axios from 'axios'; + +import { PasswordInput } from '../components/PasswordInput'; +import { APP_ENDPOINTS } from '../config/env'; +import { appEn } from '../i18n/app.en'; +import { appZhCn } from '../i18n/app.zh-cn'; +import { pickLocale } from '../i18n'; +import { useAppStore } from '../store/appStore'; +import { + PANEL_AUTH_INVALID_EVENT, + clearPanelAccessPassword, + getPanelAccessPassword, + setPanelAccessPassword, +} from '../utils/panelAccess'; + +interface PanelLoginGateProps { + bypass?: boolean; + children: ReactElement; +} + +export function PanelLoginGate({ + bypass = false, + children, +}: PanelLoginGateProps) { + const { theme, locale } = useAppStore(); + const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); + const isZh = locale === 'zh'; + 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 = isZh + ? { show: '显示密码', hide: '隐藏密码' } + : { show: 'Show password', hide: 'Hide password' }; + + useEffect(() => { + if (bypass) { + 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(isZh ? '面板访问密码错误,请重新输入。' : 'Invalid panel access password. Please try again.'); + } finally { + if (alive) setChecking(false); + } + } catch { + if (!alive) return; + clearPanelAccessPassword(); + setRequired(true); + setAuthenticated(false); + setError( + isZh + ? '无法校验面板访问状态,请检查后端连接后重试。' + : 'Unable to verify panel access. Check the backend connection and try again.', + ); + setChecking(false); + } + }; + void boot(); + return () => { + alive = false; + }; + }, [bypass, isZh]); + + useEffect(() => { + if (typeof window === 'undefined' || bypass) return undefined; + const handlePanelAuthInvalid = () => { + setRequired(true); + setAuthenticated(false); + setChecking(false); + setPassword(''); + setError( + isZh + ? '登录已失效,请重新输入面板访问密码。' + : 'Your login expired. Enter the panel access password again.', + ); + }; + window.addEventListener(PANEL_AUTH_INVALID_EVENT, handlePanelAuthInvalid); + return () => window.removeEventListener(PANEL_AUTH_INVALID_EVENT, handlePanelAuthInvalid); + }, [bypass, isZh]); + + const onSubmit = async () => { + const next = String(password || '').trim(); + if (!next) { + setError(isZh ? '请输入面板访问密码。' : 'Enter the panel access password.'); + return; + } + setSubmitting(true); + setError(''); + try { + await axios.post(`${APP_ENDPOINTS.apiBase}/panel/auth/login`, { password: next }); + setPanelAccessPassword(next); + setAuthenticated(true); + } catch (error: any) { + clearPanelAccessPassword(); + setError( + error?.response?.data?.detail + || (isZh ? '面板访问密码错误。' : 'Invalid panel access password.'), + ); + } finally { + setSubmitting(false); + } + }; + + if (checking) { + return ( +
+
+
+ Nanobot +

{t.title}

+

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

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

{t.title}

+

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

+
+ setPassword(event.target.value)} + onKeyDown={(event) => { + if (event.key === 'Enter') void onSubmit(); + }} + placeholder={isZh ? '面板访问密码' : 'Panel access password'} + toggleLabels={passwordToggleLabels} + /> + {error ?
{error}
: null} + +
+
+
+
+ ); + } + + return children; +} diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 77ebda3..e32aa05 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -3,11 +3,11 @@ import axios from 'axios'; import { useAppStore } from '../store/appStore'; import { APP_ENDPOINTS } from '../config/env'; import type { BotState, ChatMessage } from '../types/bot'; -import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../modules/dashboard/messageParser'; +import { normalizeAssistantMessageText, normalizeUserMessageText, summarizeProgressText } from '../shared/text/messageText'; import { pickLocale } from '../i18n'; import { botsSyncZhCn } from '../i18n/bots-sync.zh-cn'; import { botsSyncEn } from '../i18n/bots-sync.en'; -import { buildMonitorWsUrl } from '../utils/botAccess'; +import { buildMonitorWsUrl, notifyBotAuthInvalid } from '../utils/botAccess'; function normalizeState(v: string): 'THINKING' | 'TOOL_CALL' | 'SUCCESS' | 'ERROR' | 'INFO' { const s = (v || '').toUpperCase(); @@ -152,7 +152,12 @@ export function useBotsSync(forcedBotId?: string) { } const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots`); setBots(res.data); - } catch (error) { + } catch (error: any) { + const status = Number(error?.response?.status || 0); + if (forced && status === 401) { + setBots([]); + return; + } console.error(forced ? `Failed to fetch bot ${forced}` : 'Failed to fetch bots', error); } }; @@ -351,13 +356,16 @@ export function useBotsSync(forcedBotId?: string) { addBotLog(bot.id, String(data.text || '')); } }; - ws.onclose = () => { + ws.onclose = (event) => { const hb = heartbeatsRef.current[bot.id]; if (hb) { window.clearInterval(hb); delete heartbeatsRef.current[bot.id]; } delete socketsRef.current[bot.id]; + if (event.code === 4401 && forced === bot.id) { + notifyBotAuthInvalid(bot.id); + } }; socketsRef.current[bot.id] = ws; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 9ed9049..ecfa96a 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -117,7 +117,6 @@ export function BotDashboardModule({ botStarting: dashboard.t.botStarting, botStopping: dashboard.t.botStopping, chatDisabled: dashboard.t.chatDisabled, - close: dashboard.t.close, controlCommandsHide: dashboard.t.controlCommandsHide, controlCommandsShow: dashboard.t.controlCommandsShow, copyPrompt: dashboard.t.copyPrompt, diff --git a/frontend/src/modules/dashboard/chat/chatUtils.ts b/frontend/src/modules/dashboard/chat/chatUtils.ts new file mode 100644 index 0000000..2403610 --- /dev/null +++ b/frontend/src/modules/dashboard/chat/chatUtils.ts @@ -0,0 +1,97 @@ +import type { ChatMessage } from '../../../types/bot'; +import { normalizeAssistantMessageText, normalizeUserMessageText } from '../../../shared/text/messageText'; +import { normalizeAttachmentPaths } from '../../../shared/workspace/utils'; +import { normalizeDashboardAttachmentPath } from '../../../shared/workspace/workspaceMarkdown'; + +export function formatClock(ts: number) { + const d = new Date(ts); + const hh = String(d.getHours()).padStart(2, '0'); + const mm = String(d.getMinutes()).padStart(2, '0'); + const ss = String(d.getSeconds()).padStart(2, '0'); + return `${hh}:${mm}:${ss}`; +} + +export function formatConversationDate(ts: number, isZh: boolean) { + const d = new Date(ts); + try { + return d.toLocaleDateString(isZh ? 'zh-CN' : 'en-US', { + year: 'numeric', + month: '2-digit', + day: '2-digit', + weekday: 'short', + }); + } catch { + const y = d.getFullYear(); + const m = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${y}-${m}-${day}`; + } +} + +export function formatDateInputValue(ts: number): string { + const d = new Date(ts); + if (Number.isNaN(d.getTime())) return ''; + const year = d.getFullYear(); + const month = String(d.getMonth() + 1).padStart(2, '0'); + const day = String(d.getDate()).padStart(2, '0'); + return `${year}-${month}-${day}`; +} + +export function mapBotMessageResponseRow(row: any): ChatMessage { + const roleRaw = String(row?.role || '').toLowerCase(); + const role: ChatMessage['role'] = + roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; + const feedbackRaw = String(row?.feedback || '').trim().toLowerCase(); + const feedback: ChatMessage['feedback'] = feedbackRaw === 'up' || feedbackRaw === 'down' ? feedbackRaw : null; + return { + id: Number.isFinite(Number(row?.id)) ? Number(row.id) : undefined, + role, + text: String(row?.text || ''), + attachments: normalizeAttachmentPaths(row?.media), + ts: Number(row?.ts || Date.now()), + feedback, + kind: 'final', + } as ChatMessage; +} + +export function parseQuotedReplyBlock(input: string): { quoted: string; body: string } { + const source = String(input || ''); + const match = source.match(/\[Quoted Reply\]\s*([\s\S]*?)\s*\[\/Quoted Reply\]/i); + const quoted = normalizeAssistantMessageText(match?.[1] || ''); + const body = source.replace(/\[Quoted Reply\][\s\S]*?\[\/Quoted Reply\]\s*/gi, '').trim(); + return { quoted, body }; +} + +export function mergeConversation(messages: ChatMessage[]) { + const merged: ChatMessage[] = []; + messages + .filter((msg) => msg.role !== 'system' && (msg.text.trim().length > 0 || (msg.attachments || []).length > 0)) + .forEach((msg) => { + const parsedUser = msg.role === 'user' ? parseQuotedReplyBlock(msg.text) : { quoted: '', body: msg.text }; + const userQuoted = parsedUser.quoted; + const userBody = parsedUser.body; + const cleanText = msg.role === 'user' ? normalizeUserMessageText(userBody) : normalizeAssistantMessageText(msg.text); + const attachments = normalizeAttachmentPaths(msg.attachments).map(normalizeDashboardAttachmentPath).filter(Boolean); + if (!cleanText && attachments.length === 0 && !userQuoted) return; + const last = merged[merged.length - 1]; + if (last && last.role === msg.role) { + const normalizedLast = last.role === 'user' ? normalizeUserMessageText(last.text) : normalizeAssistantMessageText(last.text); + const normalizedCurrent = msg.role === 'user' ? normalizeUserMessageText(cleanText) : normalizeAssistantMessageText(cleanText); + const lastKind = last.kind || 'final'; + const currentKind = msg.kind || 'final'; + const sameAttachmentSet = + JSON.stringify(normalizeAttachmentPaths(last.attachments)) === JSON.stringify(attachments); + const sameQuoted = normalizeAssistantMessageText(last.quoted_reply || '') === normalizeAssistantMessageText(userQuoted); + if (lastKind === currentKind && normalizedLast === normalizedCurrent && sameAttachmentSet && sameQuoted && Math.abs(msg.ts - last.ts) < 15000) { + last.ts = msg.ts; + last.id = msg.id || last.id; + if (typeof msg.feedback !== 'undefined') { + last.feedback = msg.feedback; + } + return; + } + } + merged.push({ ...msg, text: cleanText, quoted_reply: userQuoted || undefined, attachments }); + }); + return merged.slice(-120); +} diff --git a/frontend/src/modules/dashboard/components/BotDashboardView.tsx b/frontend/src/modules/dashboard/components/BotDashboardView.tsx index 7da170d..ec7e3ab 100644 --- a/frontend/src/modules/dashboard/components/BotDashboardView.tsx +++ b/frontend/src/modules/dashboard/components/BotDashboardView.tsx @@ -1,15 +1,26 @@ -import type { ComponentProps } from 'react'; +import { Suspense, lazy, type ComponentProps } from 'react'; import { MessageCircle, MessageSquareText, X } from 'lucide-react'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; -import { CreateBotWizardModal } from '../../onboarding/CreateBotWizardModal'; -import { TopicFeedPanel } from '../topic/TopicFeedPanel'; import type { CompactPanelTab, RuntimeViewMode } from '../types'; import { BotListPanel } from './BotListPanel'; import { DashboardChatPanel } from './DashboardChatPanel'; -import { DashboardModalStack } from './DashboardModalStack'; import { RuntimePanel } from './RuntimePanel'; +const LazyCreateBotWizardModal = lazy(() => + import('../../onboarding/CreateBotWizardModal').then((module) => ({ default: module.CreateBotWizardModal })), +); +const LazyTopicFeedPanel = lazy(() => + import('../topic/TopicFeedPanel').then((module) => ({ default: module.TopicFeedPanel })), +); +const LazyDashboardModalStack = lazy(() => + import('./DashboardModalStack').then((module) => ({ default: module.DashboardModalStack })), +); + +type TopicFeedPanelProps = Parameters[0]; +type DashboardModalStackProps = Parameters[0]; +type CreateBotWizardModalProps = Parameters[0]; + export interface BotDashboardViewProps { compactMode: boolean; hasForcedBot: boolean; @@ -25,12 +36,12 @@ export interface BotDashboardViewProps { runtimeViewMode: RuntimeViewMode; hasTopicUnread: boolean; onRuntimeViewModeChange: (mode: RuntimeViewMode) => void; - topicFeedPanelProps: ComponentProps; + topicFeedPanelProps: TopicFeedPanelProps; dashboardChatPanelProps: ComponentProps; runtimePanelProps: ComponentProps; onCompactClose: () => void; - dashboardModalStackProps: ComponentProps; - createBotModalProps: ComponentProps; + dashboardModalStackProps: DashboardModalStackProps; + createBotModalProps: CreateBotWizardModalProps; } export function BotDashboardView({ @@ -54,6 +65,24 @@ export function BotDashboardView({ dashboardModalStackProps, createBotModalProps, }: BotDashboardViewProps) { + const hasDashboardOverlay = Boolean( + dashboardModalStackProps.resourceMonitorModal.open || + dashboardModalStackProps.baseConfigModal.open || + dashboardModalStackProps.paramConfigModal.open || + dashboardModalStackProps.channelConfigModal.open || + dashboardModalStackProps.topicConfigModal.open || + dashboardModalStackProps.skillsModal.open || + dashboardModalStackProps.skillMarketInstallModal.open || + dashboardModalStackProps.mcpConfigModal.open || + dashboardModalStackProps.envParamsModal.open || + dashboardModalStackProps.cronJobsModal.open || + dashboardModalStackProps.templateManagerModal.open || + dashboardModalStackProps.agentFilesModal.open || + dashboardModalStackProps.runtimeActionModal.open || + dashboardModalStackProps.workspacePreviewModal.preview || + dashboardModalStackProps.workspaceHoverCard.state, + ); + return ( <>
@@ -92,7 +121,13 @@ export function BotDashboardView({
- {runtimeViewMode === 'topic' ? : } + {runtimeViewMode === 'topic' ? ( + {isZh ? '读取主题消息中...' : 'Loading topic feed...'}
}> + + + ) : ( + + )} @@ -116,8 +151,16 @@ export function BotDashboardView({ ) : null} - - + {hasDashboardOverlay ? ( + + + + ) : null} + {createBotModalProps.open ? ( + + + + ) : null} ); } diff --git a/frontend/src/modules/dashboard/components/DashboardAgentFilesModal.tsx b/frontend/src/modules/dashboard/components/DashboardAgentFilesModal.tsx new file mode 100644 index 0000000..2f8d406 --- /dev/null +++ b/frontend/src/modules/dashboard/components/DashboardAgentFilesModal.tsx @@ -0,0 +1,76 @@ +import { DrawerShell } from '../../../components/DrawerShell'; +import { MarkdownLiteEditor } from '../../../components/markdown/MarkdownLiteEditor'; +import type { AgentTab } from '../types'; +import './DashboardManagementModals.css'; +import './DashboardSupportModals.css'; + +interface AgentFilesModalLabels { + agentFiles: string; + cancel: string; + close: string; + saveFiles: string; +} + +const AGENT_FILE_TABS: AgentTab[] = ['AGENTS', 'SOUL', 'USER', 'TOOLS', 'IDENTITY']; + +interface AgentFilesModalProps { + open: boolean; + agentTab: AgentTab; + tabValue: string; + isSaving: boolean; + labels: AgentFilesModalLabels; + onClose: () => void; + onAgentTabChange: (tab: AgentTab) => void; + onTabValueChange: (value: string) => void; + onSave: () => Promise | void; +} + +export function AgentFilesModal({ + open, + agentTab, + tabValue, + isSaving, + labels, + onClose, + onAgentTabChange, + onTabValueChange, + onSave, +}: AgentFilesModalProps) { + if (!open) return null; + + return ( + +
{`${agentTab}.md`}
+
+ + +
+ + )} + > +
+
+
+ {AGENT_FILE_TABS.map((tab) => ( + + ))} +
+ +
+
+
+ ); +} diff --git a/frontend/src/modules/dashboard/components/DashboardChannelTopicModals.tsx b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx similarity index 60% rename from frontend/src/modules/dashboard/components/DashboardChannelTopicModals.tsx rename to frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx index a145c63..b830f1c 100644 --- a/frontend/src/modules/dashboard/components/DashboardChannelTopicModals.tsx +++ b/frontend/src/modules/dashboard/components/DashboardChannelConfigModal.tsx @@ -1,10 +1,10 @@ -import { ChevronDown, ChevronUp, ExternalLink, Plus, RefreshCw, Save, Trash2, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, ExternalLink, Plus, Save, Trash2, X } from 'lucide-react'; import type { RefObject } from 'react'; import { DrawerShell } from '../../../components/DrawerShell'; import { PasswordInput } from '../../../components/PasswordInput'; import { LucentIconButton } from '../../../components/lucent/LucentIconButton'; -import type { BotChannel, BotTopic, ChannelType, TopicPresetTemplate, WeixinLoginStatus } from '../types'; +import type { BotChannel, ChannelType, WeixinLoginStatus } from '../types'; import './DashboardManagementModals.css'; interface PasswordToggleLabels { @@ -593,334 +593,3 @@ export function ChannelConfigModal({ ); } - -interface TopicConfigModalProps { - open: boolean; - topics: BotTopic[]; - expandedTopicByKey: Record; - newTopicPanelOpen: boolean; - topicPresetMenuOpen: boolean; - newTopicAdvancedOpen: boolean; - newTopicSourceLabel: string; - newTopicKey: string; - newTopicName: string; - newTopicDescription: string; - newTopicPurpose: string; - newTopicIncludeWhen: string; - newTopicExcludeWhen: string; - newTopicExamplesPositive: string; - newTopicExamplesNegative: string; - newTopicPriority: string; - effectiveTopicPresetTemplates: TopicPresetTemplate[]; - topicPresetMenuRef: RefObject; - isSavingTopic: boolean; - hasSelectedBot: boolean; - isZh: boolean; - labels: Record; - onClose: () => void; - getTopicUiKey: (topic: Pick, fallbackIndex: number) => string; - countRoutingTextList: (raw: string) => number; - onUpdateTopicLocal: (index: number, patch: Partial) => void; - onToggleExpandedTopic: (key: string) => void; - onRemoveTopic: (topic: BotTopic) => Promise | void; - onSaveTopic: (topic: BotTopic) => Promise | void; - onSetNewTopicPanelOpen: (value: boolean) => void; - onSetTopicPresetMenuOpen: (value: boolean | ((prev: boolean) => boolean)) => void; - onSetNewTopicAdvancedOpen: (value: boolean | ((prev: boolean) => boolean)) => void; - onResetNewTopicDraft: () => void; - onNormalizeTopicKeyInput: (value: string) => string; - onSetNewTopicKey: (value: string) => void; - onSetNewTopicName: (value: string) => void; - onSetNewTopicDescription: (value: string) => void; - onSetNewTopicPurpose: (value: string) => void; - onSetNewTopicIncludeWhen: (value: string) => void; - onSetNewTopicExcludeWhen: (value: string) => void; - onSetNewTopicExamplesPositive: (value: string) => void; - onSetNewTopicExamplesNegative: (value: string) => void; - onSetNewTopicPriority: (value: string) => void; - onBeginTopicCreate: (presetId: string) => void; - onResolvePresetLabel: (preset: TopicPresetTemplate) => string; - onAddTopic: () => Promise | void; -} - -export function TopicConfigModal({ - open, - topics, - expandedTopicByKey, - newTopicPanelOpen, - topicPresetMenuOpen, - newTopicAdvancedOpen, - newTopicSourceLabel, - newTopicKey, - newTopicName, - newTopicDescription, - newTopicPurpose, - newTopicIncludeWhen, - newTopicExcludeWhen, - newTopicExamplesPositive, - newTopicExamplesNegative, - newTopicPriority, - effectiveTopicPresetTemplates, - topicPresetMenuRef, - isSavingTopic, - hasSelectedBot, - isZh, - labels, - onClose, - getTopicUiKey, - countRoutingTextList, - onUpdateTopicLocal, - onToggleExpandedTopic, - onRemoveTopic, - onSaveTopic, - onSetNewTopicPanelOpen, - onSetTopicPresetMenuOpen, - onSetNewTopicAdvancedOpen, - onResetNewTopicDraft, - onNormalizeTopicKeyInput, - onSetNewTopicKey, - onSetNewTopicName, - onSetNewTopicDescription, - onSetNewTopicPurpose, - onSetNewTopicIncludeWhen, - onSetNewTopicExcludeWhen, - onSetNewTopicExamplesPositive, - onSetNewTopicExamplesNegative, - onSetNewTopicPriority, - onBeginTopicCreate, - onResolvePresetLabel, - onAddTopic, -}: TopicConfigModalProps) { - if (!open) return null; - - return ( - -
{labels.topicAddHint}
-
- - {topicPresetMenuOpen ? ( -
- {effectiveTopicPresetTemplates.map((preset) => ( - - ))} - -
- ) : null} -
- - ) : undefined - )} - > -
-
- {topics.length === 0 ? ( -
{labels.topicEmpty}
- ) : ( - topics.map((topic, idx) => { - const uiKey = getTopicUiKey(topic, idx); - const expanded = expandedTopicByKey[uiKey] ?? idx === 0; - const includeCount = countRoutingTextList(String(topic.routing_include_when || '')); - const excludeCount = countRoutingTextList(String(topic.routing_exclude_when || '')); - return ( -
-
-
- {topic.topic_key} -
{topic.name || topic.topic_key}
- {!expanded ? ( -
- {`${labels.topicPriority}: ${topic.routing_priority || '50'} · ${isZh ? '命中' : 'include'} ${includeCount} · ${isZh ? '排除' : 'exclude'} ${excludeCount}`} -
- ) : null} -
-
- - void onRemoveTopic(topic)} - tooltip={labels.delete} - aria-label={labels.delete} - > - - - onToggleExpandedTopic(uiKey)} - tooltip={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')} - aria-label={expanded ? (isZh ? '收起详情' : 'Collapse') : (isZh ? '展开详情' : 'Expand')} - > - {expanded ? : } - -
-
- {expanded ? ( - <> -
-
- - onUpdateTopicLocal(idx, { name: e.target.value })} placeholder={labels.topicName} /> -
-
- - onUpdateTopicLocal(idx, { routing_priority: e.target.value })} /> -
-
- -