From 069b7504417e64edb4c45592e1bc3ada3a6c58ac Mon Sep 17 00:00:00 2001 From: "mula.liu" Date: Sat, 14 Mar 2026 15:44:11 +0800 Subject: [PATCH] v0.1.4-p2 --- .env.prod.example | 14 +- backend/.env.example | 9 +- backend/api/topic_router.py | 48 +-- backend/core/config_manager.py | 26 ++ backend/core/database.py | 28 +- backend/core/settings.py | 21 +- backend/main.py | 164 ++++++-- backend/services/topic_runtime/bridge.py | 4 +- backend/services/topic_service.py | 393 +----------------- docker-compose.prod.yml | 6 +- frontend/src/hooks/useBotsSync.ts | 22 +- frontend/src/i18n/channels.en.ts | 21 + frontend/src/i18n/channels.zh-cn.ts | 21 + frontend/src/i18n/dashboard.en.ts | 5 - frontend/src/i18n/dashboard.zh-cn.ts | 5 - .../modules/dashboard/BotDashboardModule.tsx | 367 +++++++++++++--- 16 files changed, 580 insertions(+), 574 deletions(-) diff --git a/.env.prod.example b/.env.prod.example index e79c433..08d6657 100644 --- a/.env.prod.example +++ b/.env.prod.example @@ -30,20 +30,24 @@ NPM_REGISTRY=https://registry.npmmirror.com # DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard # MySQL example: # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard -DATABASE_URL=postgresql+psycopg://postgres:change_me@127.0.0.1:5432/nanobot_dashboard +DATABASE_URL=postgresql+psycopg://postgres:change_me@127.0.0.1:5432/dashboard +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=40 +DATABASE_POOL_TIMEOUT=30 +DATABASE_POOL_RECYCLE=1800 # Redis cache (optional) REDIS_ENABLED=true REDIS_URL=redis://127.0.0.1:6379/8 -REDIS_PREFIX=dashboard_nanobot +REDIS_PREFIX=nanobot REDIS_DEFAULT_TTL=60 +# Chat history page size for upward lazy loading (per request) +CHAT_PULL_PAGE_SIZE=60 + # Panel access protection PANEL_ACCESS_PASSWORD=change_me_panel_password -# Internal URL used by built-in topic_mcp server inside bot container -TOPIC_MCP_INTERNAL_URL=http://host.docker.internal:8000/api/mcp/topic - # Max upload size for backend validation (MB) UPLOAD_MAX_MB=200 diff --git a/backend/.env.example b/backend/.env.example index b44ada7..05d0664 100644 --- a/backend/.env.example +++ b/backend/.env.example @@ -12,6 +12,10 @@ BOTS_WORKSPACE_ROOT=../workspace/bots # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # Show SQL statements in backend logs (debug only). DATABASE_ECHO=true +DATABASE_POOL_SIZE=20 +DATABASE_MAX_OVERFLOW=40 +DATABASE_POOL_TIMEOUT=30 +DATABASE_POOL_RECYCLE=1800 # Redis cache REDIS_ENABLED=false @@ -20,12 +24,11 @@ REDIS_ENABLED=false REDIS_URL= REDIS_PREFIX=dashboard_nanobot REDIS_DEFAULT_TTL=60 +# Chat history page size for upward lazy loading (per request) +CHAT_PULL_PAGE_SIZE=60 # Optional panel-level access password for all backend API/WS calls. PANEL_ACCESS_PASSWORD= -# Internal URL used by built-in topic_mcp server inside bot container -TOPIC_MCP_INTERNAL_URL=http://host.docker.internal:8000/api/mcp/topic - # Max upload size for backend validation (MB) UPLOAD_MAX_MB=100 # Workspace files that should use direct download behavior in dashboard diff --git a/backend/api/topic_router.py b/backend/api/topic_router.py index 828e415..a8762f5 100644 --- a/backend/api/topic_router.py +++ b/backend/api/topic_router.py @@ -2,8 +2,7 @@ import json from datetime import datetime from typing import Any, Dict, List, Optional -from fastapi import APIRouter, Depends, HTTPException, Request -from fastapi.responses import JSONResponse, Response +from fastapi import APIRouter, Depends, HTTPException from pydantic import BaseModel from sqlalchemy import func from sqlmodel import Session, select @@ -12,13 +11,9 @@ from core.database import get_session from models.bot import BotInstance from models.topic import TopicItem, TopicTopic from services.topic_service import ( - TOPIC_MCP_TOKEN_HEADER, _TOPIC_KEY_RE, - _handle_topic_mcp_rpc_item, - _jsonrpc_error, _list_topics, _normalize_topic_key, - _resolve_topic_mcp_bot_id_by_token, _topic_item_to_dict, _topic_to_dict, ) @@ -248,44 +243,3 @@ def mark_bot_topic_item_read(bot_id: str, item_id: int, session: Session = Depen "bot_id": bot_id, "item": _topic_item_to_dict(row), } - - -@router.post("/api/mcp/topic") -async def topic_mcp_entry(request: Request, session: Session = Depends(get_session)): - token = str(request.headers.get(TOPIC_MCP_TOKEN_HEADER) or "").strip() - if not token: - auth = str(request.headers.get("authorization") or "").strip() - if auth.lower().startswith("bearer "): - token = auth[7:].strip() - if not token: - raise HTTPException(status_code=401, detail="Missing topic_mcp token") - - bot_id = _resolve_topic_mcp_bot_id_by_token(session, token) - if not bot_id: - raise HTTPException(status_code=401, detail="Invalid topic_mcp token") - - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=401, detail="Invalid topic_mcp token") - - try: - payload = await request.json() - except Exception: - return JSONResponse(status_code=400, content=_jsonrpc_error(None, -32700, "Parse error")) - - if isinstance(payload, list): - if not payload: - return JSONResponse(status_code=400, content=_jsonrpc_error(None, -32600, "Invalid Request")) - responses: List[Dict[str, Any]] = [] - for item in payload: - resp = _handle_topic_mcp_rpc_item(session, bot_id, item) - if resp is not None: - responses.append(resp) - if not responses: - return Response(status_code=204) - return JSONResponse(content=responses) - - resp = _handle_topic_mcp_rpc_item(session, bot_id, payload) - if resp is None: - return Response(status_code=204) - return JSONResponse(content=resp) diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index 64f9979..5cc46f6 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -161,6 +161,32 @@ class BotConfigManager: } continue + if channel_type == "email": + channels_cfg["email"] = { + "enabled": enabled, + "consentGranted": bool(extra.get("consentGranted", False)), + "imapHost": extra.get("imapHost", ""), + "imapPort": max(1, min(int(extra.get("imapPort", 993) or 993), 65535)), + "imapUsername": extra.get("imapUsername", ""), + "imapPassword": extra.get("imapPassword", ""), + "imapMailbox": extra.get("imapMailbox", "INBOX"), + "imapUseSsl": bool(extra.get("imapUseSsl", True)), + "smtpHost": extra.get("smtpHost", ""), + "smtpPort": max(1, min(int(extra.get("smtpPort", 587) or 587), 65535)), + "smtpUsername": extra.get("smtpUsername", ""), + "smtpPassword": extra.get("smtpPassword", ""), + "smtpUseTls": bool(extra.get("smtpUseTls", True)), + "smtpUseSsl": bool(extra.get("smtpUseSsl", False)), + "fromAddress": extra.get("fromAddress", ""), + "autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)), + "pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds", 30) or 30)), + "markSeen": bool(extra.get("markSeen", True)), + "maxBodyChars": max(1, int(extra.get("maxBodyChars", 12000) or 12000)), + "subjectPrefix": extra.get("subjectPrefix", "Re: "), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), + } + continue + # Fallback for future custom channels. channels_cfg[channel_type] = { "enabled": enabled, diff --git a/backend/core/database.py b/backend/core/database.py index c0fbae7..58869f3 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -1,13 +1,37 @@ from sqlalchemy import inspect, text from sqlmodel import SQLModel, Session, create_engine -from core.settings import DATABASE_ECHO, DATABASE_URL +from core.settings import ( + DATABASE_ECHO, + DATABASE_ENGINE, + DATABASE_MAX_OVERFLOW, + DATABASE_POOL_RECYCLE, + DATABASE_POOL_SIZE, + DATABASE_POOL_TIMEOUT, + DATABASE_URL, +) # Ensure table models are registered in SQLModel metadata before create_all. from models import bot as _bot_models # noqa: F401 from models import topic as _topic_models # noqa: F401 -engine = create_engine(DATABASE_URL, echo=DATABASE_ECHO) +_engine_kwargs = { + "echo": DATABASE_ECHO, +} +if DATABASE_ENGINE == "sqlite": + _engine_kwargs["connect_args"] = {"check_same_thread": False} +else: + _engine_kwargs.update( + { + "pool_pre_ping": True, + "pool_size": DATABASE_POOL_SIZE, + "max_overflow": DATABASE_MAX_OVERFLOW, + "pool_timeout": DATABASE_POOL_TIMEOUT, + "pool_recycle": DATABASE_POOL_RECYCLE, + } + ) + +engine = create_engine(DATABASE_URL, **_engine_kwargs) def _ensure_botinstance_columns() -> None: diff --git a/backend/core/settings.py b/backend/core/settings.py index 7c6f9b4..10f8378 100644 --- a/backend/core/settings.py +++ b/backend/core/settings.py @@ -55,10 +55,6 @@ def _env_int(name: str, default: int, min_value: int, max_value: int) -> int: return max(min_value, min(max_value, value)) -def _is_truthy(raw: object) -> bool: - return str(raw or "").strip().lower() in {"1", "true", "yes", "on", "y"} - - def _normalize_extension(raw: str) -> str: text = str(raw or "").strip().lower() if not text: @@ -178,6 +174,10 @@ DATABASE_URL: Final[str] = _normalize_database_url( DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL) DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) +DATABASE_POOL_SIZE: Final[int] = _env_int("DATABASE_POOL_SIZE", 20, 1, 200) +DATABASE_MAX_OVERFLOW: Final[int] = _env_int("DATABASE_MAX_OVERFLOW", 40, 0, 200) +DATABASE_POOL_TIMEOUT: Final[int] = _env_int("DATABASE_POOL_TIMEOUT", 30, 1, 300) +DATABASE_POOL_RECYCLE: Final[int] = _env_int("DATABASE_POOL_RECYCLE", 1800, 30, 86400) UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048) WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = ( ".pdf", @@ -222,19 +222,8 @@ REDIS_ENABLED: Final[bool] = _env_bool("REDIS_ENABLED", False) REDIS_URL: Final[str] = str(os.getenv("REDIS_URL") or "").strip() REDIS_PREFIX: Final[str] = str(os.getenv("REDIS_PREFIX") or "dashboard_nanobot").strip() or "dashboard_nanobot" REDIS_DEFAULT_TTL: Final[int] = _env_int("REDIS_DEFAULT_TTL", 60, 1, 86400) +CHAT_PULL_PAGE_SIZE: Final[int] = _env_int("CHAT_PULL_PAGE_SIZE", 60, 10, 500) PANEL_ACCESS_PASSWORD: Final[str] = str(os.getenv("PANEL_ACCESS_PASSWORD") or "").strip() -_topic_mcp_default = "http://host.docker.internal:8000/api/mcp/topic" -_topic_mcp_from_env = str(os.getenv("TOPIC_MCP_INTERNAL_URL") or "").strip() -_topic_mcp_from_backend_env = str(_backend_env_values.get("TOPIC_MCP_INTERNAL_URL") or "").strip() -_dev_mode = _is_truthy(os.getenv("APP_RELOAD")) or str(os.getenv("APP_ENV") or "").strip().lower() in { - "dev", - "development", - "local", -} -if _dev_mode and _topic_mcp_from_backend_env: - TOPIC_MCP_INTERNAL_URL: Final[str] = _topic_mcp_from_backend_env -else: - TOPIC_MCP_INTERNAL_URL: Final[str] = _topic_mcp_from_env or _topic_mcp_default TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve() AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json" diff --git a/backend/main.py b/backend/main.py index ff5fbf2..ddbc1df 100644 --- a/backend/main.py +++ b/backend/main.py @@ -30,6 +30,7 @@ from core.speech_service import ( ) from core.settings import ( BOTS_WORKSPACE_ROOT, + CHAT_PULL_PAGE_SIZE, DATA_ROOT, DATABASE_ECHO, DATABASE_ENGINE, @@ -50,7 +51,6 @@ from core.settings import ( STT_ENABLED, STT_MAX_AUDIO_SECONDS, STT_MODEL, - TOPIC_MCP_INTERNAL_URL, TOPIC_PRESET_TEMPLATES, TOPIC_PRESETS_TEMPLATES_FILE, UPLOAD_MAX_MB, @@ -61,11 +61,6 @@ from core.settings import ( from models.bot import BotInstance, BotMessage, NanobotImage from models.topic import TopicBotSettings, TopicItem, TopicTopic from api.topic_router import router as topic_router -from services.topic_service import ( - TOPIC_MCP_SERVER_NAME, - _annotate_locked_mcp_servers, - _ensure_topic_mcp_server, -) from services.topic_runtime import publish_runtime_topic_packet app = FastAPI(title="Dashboard Nanobot API") @@ -402,7 +397,6 @@ def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool: "/api/panel/auth/login", "/api/health", "/api/health/cache", - "/api/mcp/topic", }: return False if _is_bot_panel_management_api_path(raw, verb): @@ -487,6 +481,11 @@ def _cache_key_bot_messages(bot_id: str, limit: int) -> str: return f"bot:messages:{bot_id}:limit:{limit}" +def _cache_key_bot_messages_page(bot_id: str, limit: int, before_id: Optional[int]) -> str: + cursor = str(int(before_id)) if isinstance(before_id, int) and before_id > 0 else "latest" + return f"bot:messages:page:{bot_id}:before:{cursor}:limit:{limit}" + + def _cache_key_images() -> str: return "images:list" @@ -511,7 +510,6 @@ async def on_startup(): print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}") print(f"🧠 Redis 缓存: {'enabled' if cache.ping() else 'disabled'} ({REDIS_URL if REDIS_ENABLED else 'not configured'})") print(f"🔐 面板访问密码: {'enabled' if str(PANEL_ACCESS_PASSWORD or '').strip() else 'disabled'}") - print(f"🧩 Topic MCP internal URL: {TOPIC_MCP_INTERNAL_URL}") init_database() cache.delete_prefix("") with Session(engine) as session: @@ -553,6 +551,9 @@ def get_system_defaults(): "workspace": { "download_extensions": list(WORKSPACE_DOWNLOAD_EXTENSIONS), }, + "chat": { + "pull_page_size": CHAT_PULL_PAGE_SIZE, + }, "topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES, "speech": { "enabled": STT_ENABLED, @@ -811,6 +812,29 @@ def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Di external_app_id = str(cfg.get("appId") or "") app_secret = str(cfg.get("secret") or "") extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} + elif ctype == "email": + extra = { + "consentGranted": bool(cfg.get("consentGranted", False)), + "imapHost": str(cfg.get("imapHost") or ""), + "imapPort": int(cfg.get("imapPort") or 993), + "imapUsername": str(cfg.get("imapUsername") or ""), + "imapPassword": str(cfg.get("imapPassword") or ""), + "imapMailbox": str(cfg.get("imapMailbox") or "INBOX"), + "imapUseSsl": bool(cfg.get("imapUseSsl", True)), + "smtpHost": str(cfg.get("smtpHost") or ""), + "smtpPort": int(cfg.get("smtpPort") or 587), + "smtpUsername": str(cfg.get("smtpUsername") or ""), + "smtpPassword": str(cfg.get("smtpPassword") or ""), + "smtpUseTls": bool(cfg.get("smtpUseTls", True)), + "smtpUseSsl": bool(cfg.get("smtpUseSsl", False)), + "fromAddress": str(cfg.get("fromAddress") or ""), + "autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)), + "pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30), + "markSeen": bool(cfg.get("markSeen", True)), + "maxBodyChars": int(cfg.get("maxBodyChars") or 12000), + "subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "), + "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), + } else: external_app_id = str( cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "" @@ -883,6 +907,30 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]: "secret": app_secret, "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), } + if ctype == "email": + return { + "enabled": enabled, + "consentGranted": bool(extra.get("consentGranted", False)), + "imapHost": str(extra.get("imapHost") or ""), + "imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)), + "imapUsername": str(extra.get("imapUsername") or ""), + "imapPassword": str(extra.get("imapPassword") or ""), + "imapMailbox": str(extra.get("imapMailbox") or "INBOX"), + "imapUseSsl": bool(extra.get("imapUseSsl", True)), + "smtpHost": str(extra.get("smtpHost") or ""), + "smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)), + "smtpUsername": str(extra.get("smtpUsername") or ""), + "smtpPassword": str(extra.get("smtpPassword") or ""), + "smtpUseTls": bool(extra.get("smtpUseTls", True)), + "smtpUseSsl": bool(extra.get("smtpUseSsl", False)), + "fromAddress": str(extra.get("fromAddress") or ""), + "autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)), + "pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)), + "markSeen": bool(extra.get("markSeen", True)), + "maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)), + "subjectPrefix": str(extra.get("subjectPrefix") or "Re: "), + "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), + } merged = dict(extra) merged.update( { @@ -2374,40 +2422,14 @@ def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)): if not bot: raise HTTPException(status_code=404, detail="Bot not found") config_data = _read_bot_config(bot_id) - tools_cfg = config_data.get("tools") + tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {} if not isinstance(tools_cfg, dict): tools_cfg = {} mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers")) - mcp_servers = _annotate_locked_mcp_servers(mcp_servers) return { "bot_id": bot_id, "mcp_servers": mcp_servers, - "locked_servers": [TOPIC_MCP_SERVER_NAME] if TOPIC_MCP_SERVER_NAME in mcp_servers else [], - "restart_required": True, - } - - -@app.post("/api/bots/{bot_id}/mcp-config/topic-mcp/enable") -def enable_bot_topic_mcp(bot_id: str, session: Session = Depends(get_session)): - bot = session.get(BotInstance, bot_id) - if not bot: - raise HTTPException(status_code=404, detail="Bot not found") - config_data = _read_bot_config(bot_id) - if not isinstance(config_data, dict): - config_data = {} - _ensure_topic_mcp_server(bot_id, config_data=config_data, persist=True) - config_data = _read_bot_config(bot_id) - tools_cfg = config_data.get("tools") - if not isinstance(tools_cfg, dict): - tools_cfg = {} - mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers")) - mcp_servers = _annotate_locked_mcp_servers(mcp_servers) - _invalidate_bot_detail_cache(bot_id) - return { - "status": "enabled", - "bot_id": bot_id, - "mcp_servers": mcp_servers, - "locked_servers": [TOPIC_MCP_SERVER_NAME] if TOPIC_MCP_SERVER_NAME in mcp_servers else [], + "locked_servers": [], "restart_required": True, } @@ -2431,7 +2453,7 @@ def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, sessi return { "status": "updated", "bot_id": bot_id, - "mcp_servers": _annotate_locked_mcp_servers(mcp_servers), + "mcp_servers": mcp_servers, "locked_servers": [], "restart_required": True, } @@ -2448,7 +2470,18 @@ def test_bot_mcp_config(bot_id: str, payload: BotMcpConfigTestRequest, session: "headers": payload.headers or {}, "toolTimeout": payload.tool_timeout if payload.tool_timeout is not None else 10, } - return _probe_mcp_server(cfg, bot_id=bot_id) + result = _probe_mcp_server(cfg, bot_id=bot_id) + if not result.get("ok"): + logger.error( + "mcp probe failed bot_id=%s transport=%s url=%s probe_from=%s status_code=%s message=%s", + bot_id, + result.get("transport"), + cfg.get("url"), + result.get("probe_from"), + result.get("status_code"), + result.get("message"), + ) + return result @app.get("/api/bots/{bot_id}/env-params") @@ -2895,6 +2928,61 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends( return payload +@app.get("/api/bots/{bot_id}/messages/page") +def list_bot_messages_page( + bot_id: str, + limit: int = CHAT_PULL_PAGE_SIZE, + before_id: Optional[int] = None, + session: Session = Depends(get_session), +): + bot = session.get(BotInstance, bot_id) + if not bot: + raise HTTPException(status_code=404, detail="Bot not found") + + safe_limit = max(1, min(int(limit), 500)) + safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None + cache_key = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id) + cached = cache.get_json(cache_key) + if isinstance(cached, dict) and isinstance(cached.get("items"), list): + return cached + + stmt = ( + select(BotMessage) + .where(BotMessage.bot_id == bot_id) + .order_by(BotMessage.created_at.desc(), BotMessage.id.desc()) + .limit(safe_limit + 1) + ) + if safe_before_id is not None: + stmt = stmt.where(BotMessage.id < safe_before_id) + + rows = session.exec(stmt).all() + has_more = len(rows) > safe_limit + if has_more: + rows = rows[:safe_limit] + ordered = list(reversed(rows)) + items = [ + { + "id": row.id, + "bot_id": row.bot_id, + "role": row.role, + "text": row.text, + "media": _parse_message_media(bot_id, getattr(row, "media_json", None)), + "feedback": str(getattr(row, "feedback", "") or "").strip() or None, + "ts": int(row.created_at.timestamp() * 1000), + } + for row in ordered + ] + next_before_id = rows[-1].id if rows else None + payload = { + "items": items, + "has_more": bool(has_more), + "next_before_id": next_before_id, + "limit": safe_limit, + } + cache.set_json(cache_key, payload, ttl=30) + return payload + + @app.put("/api/bots/{bot_id}/messages/{message_id}/feedback") def update_bot_message_feedback( bot_id: str, diff --git a/backend/services/topic_runtime/bridge.py b/backend/services/topic_runtime/bridge.py index c516e04..de1cf7b 100644 --- a/backend/services/topic_runtime/bridge.py +++ b/backend/services/topic_runtime/bridge.py @@ -3,7 +3,7 @@ from typing import Any, Dict, Optional from sqlmodel import Session -from services.topic_service import _has_topic_mcp_server, _topic_publish_internal +from services.topic_service import _topic_publish_internal from .publisher import build_topic_publish_payload @@ -19,8 +19,6 @@ def publish_runtime_topic_packet( packet_type = str(packet.get("type") or "").strip().upper() if packet_type not in {"ASSISTANT_MESSAGE", "BUS_EVENT"} or not persisted_message_id: return - if not _has_topic_mcp_server(bot_id): - return topic_payload = build_topic_publish_payload( bot_id, diff --git a/backend/services/topic_service.py b/backend/services/topic_service.py index 54f2958..0de79c7 100644 --- a/backend/services/topic_service.py +++ b/backend/services/topic_service.py @@ -1,69 +1,17 @@ import json -import logging -import os import re -import secrets from datetime import datetime from typing import Any, Dict, List, Optional from sqlmodel import Session, select -from core.settings import BOTS_WORKSPACE_ROOT, TOPIC_MCP_INTERNAL_URL -from models.bot import BotInstance from models.topic import TopicItem, TopicTopic -logger = logging.getLogger("dashboard.topic_mcp") - -BOT_ID_PATTERN = re.compile(r"^[A-Za-z0-9_]+$") -TOPIC_MCP_SERVER_NAME = "topic_mcp" -TOPIC_MCP_TOKEN_HEADER = "x-topic-mcp-token" -TOPIC_MCP_DEFAULT_URL = TOPIC_MCP_INTERNAL_URL -TOPIC_MCP_DEFAULT_TIMEOUT = 30 -TOPIC_MCP_PROTOCOL_VERSION = "2025-03-26" TOPIC_DEDUPE_WINDOW_SECONDS = 10 * 60 TOPIC_LEVEL_SET = {"info", "warn", "error", "success"} _TOPIC_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$") -def _bot_data_root(bot_id: str) -> str: - return os.path.join(BOTS_WORKSPACE_ROOT, bot_id, ".nanobot") - - -def _config_json_path(bot_id: str) -> str: - return os.path.join(_bot_data_root(bot_id), "config.json") - - -def _read_bot_config(bot_id: str) -> Dict[str, Any]: - path = _config_json_path(bot_id) - if not os.path.isfile(path): - return {} - try: - with open(path, "r", encoding="utf-8") as f: - data = json.load(f) - return data if isinstance(data, dict) else {} - except Exception: - return {} - - -def _write_bot_config(bot_id: str, config_data: Dict[str, Any]) -> None: - path = _config_json_path(bot_id) - os.makedirs(os.path.dirname(path), exist_ok=True) - tmp = f"{path}.tmp" - with open(tmp, "w", encoding="utf-8") as f: - json.dump(config_data, f, ensure_ascii=False, indent=2) - os.replace(tmp, path) - - -def _dict_get_ci(raw: Any, key: str) -> Any: - if not isinstance(raw, dict): - return None - wanted = str(key or "").strip().lower() - for k, v in raw.items(): - if str(k or "").strip().lower() == wanted: - return v - return None - - def _as_bool(value: Any) -> bool: if isinstance(value, bool): return value @@ -71,98 +19,6 @@ def _as_bool(value: Any) -> bool: return text in {"1", "true", "yes", "on", "y"} -def _extract_topic_mcp_token(server_cfg: Any) -> str: - headers = server_cfg.get("headers") if isinstance(server_cfg, dict) else None - return str(_dict_get_ci(headers, TOPIC_MCP_TOKEN_HEADER) or "").strip() - - -def _generate_topic_mcp_token(bot_id: str) -> str: - return f"{bot_id}.{secrets.token_urlsafe(24)}" - - -def _build_locked_topic_mcp_server(bot_id: str, token: str) -> Dict[str, Any]: - fixed_token = str(token or "").strip() or _generate_topic_mcp_token(bot_id) - return { - "type": "streamableHttp", - "url": TOPIC_MCP_DEFAULT_URL, - "headers": {TOPIC_MCP_TOKEN_HEADER: fixed_token}, - "toolTimeout": TOPIC_MCP_DEFAULT_TIMEOUT, - } - - -def _annotate_locked_mcp_servers(raw_servers: Dict[str, Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: - rows: Dict[str, Dict[str, Any]] = {} - for name, cfg in raw_servers.items(): - if not isinstance(cfg, dict): - continue - row = dict(cfg) - row["locked"] = name == TOPIC_MCP_SERVER_NAME - rows[name] = row - return rows - - -def _ensure_topic_mcp_server(bot_id: str, config_data: Optional[Dict[str, Any]] = None, persist: bool = True) -> Dict[str, Any]: - working = config_data if isinstance(config_data, dict) else _read_bot_config(bot_id) - tools_cfg = working.get("tools") - if not isinstance(tools_cfg, dict): - tools_cfg = {} - mcp_servers = tools_cfg.get("mcpServers") - if not isinstance(mcp_servers, dict): - mcp_servers = {} - - existing_server = mcp_servers.get(TOPIC_MCP_SERVER_NAME) - existing_token = _extract_topic_mcp_token(existing_server) - locked_server = _build_locked_topic_mcp_server(bot_id, existing_token) - - changed = mcp_servers.get(TOPIC_MCP_SERVER_NAME) != locked_server - mcp_servers[TOPIC_MCP_SERVER_NAME] = locked_server - tools_cfg["mcpServers"] = mcp_servers - working["tools"] = tools_cfg - - if persist and changed: - _write_bot_config(bot_id, working) - return locked_server - - -def _resolve_topic_mcp_bot_id_by_token(session: Session, token: str) -> Optional[str]: - incoming = str(token or "").strip() - if not incoming: - return None - - candidates: List[str] = [] - hinted_bot_id = incoming.split(".", 1)[0].strip() - if hinted_bot_id and BOT_ID_PATTERN.fullmatch(hinted_bot_id): - candidates.append(hinted_bot_id) - for bot in session.exec(select(BotInstance)).all(): - if bot.id not in candidates: - candidates.append(bot.id) - - for bot_id in candidates: - config_data = _read_bot_config(bot_id) - tools_cfg = config_data.get("tools") - if not isinstance(tools_cfg, dict): - continue - mcp_servers = tools_cfg.get("mcpServers") - if not isinstance(mcp_servers, dict): - continue - expected = _extract_topic_mcp_token(mcp_servers.get(TOPIC_MCP_SERVER_NAME)) - if expected and secrets.compare_digest(expected, incoming): - return bot_id - return None - - -def _has_topic_mcp_server(bot_id: str) -> bool: - config_data = _read_bot_config(bot_id) - tools_cfg = config_data.get("tools") - if not isinstance(tools_cfg, dict): - return False - mcp_servers = tools_cfg.get("mcpServers") - if not isinstance(mcp_servers, dict): - return False - token = _extract_topic_mcp_token(mcp_servers.get(TOPIC_MCP_SERVER_NAME)) - return bool(token) - - def _normalize_topic_key(raw: Any) -> str: value = str(raw or "").strip().lower() if not value: @@ -170,14 +26,6 @@ def _normalize_topic_key(raw: Any) -> str: return value -def _ensure_topic_defaults(session: Session, bot_id: str) -> None: - # Deprecated: topic feed global switch/fallback removed. - # Keep as no-op for call-site compatibility. - _ = session - _ = bot_id - return None - - def _parse_json_dict(raw: str) -> Dict[str, Any]: text = str(raw or "").strip() if not text: @@ -234,7 +82,7 @@ def _topic_item_to_dict(row: TopicItem) -> Dict[str, Any]: "level": str(row.level or "info").strip().lower(), "tags": _parse_json_list(row.tags_json or "[]"), "view": _parse_json_dict(row.view_json or "{}"), - "source": row.source or "mcp", + "source": row.source or "dashboard", "dedupe_key": row.dedupe_key or "", "is_read": bool(row.is_read), "created_at": row.created_at.isoformat() if row.created_at else None, @@ -466,7 +314,7 @@ def _topic_publish_internal(session: Session, bot_id: str, payload: Dict[str, An view = payload.get("view") view_json = json.dumps(view, ensure_ascii=False) if isinstance(view, dict) else None - source = str(payload.get("source") or "mcp").strip().lower() or "mcp" + source = str(payload.get("source") or "dashboard").strip().lower() or "dashboard" now = datetime.utcnow() item = TopicItem( bot_id=bot_id, @@ -490,240 +338,3 @@ def _topic_publish_internal(session: Session, bot_id: str, payload: Dict[str, An "item": _topic_item_to_dict(item), "route": route_result, } - - -def _jsonrpc_success(rpc_id: Any, result: Any) -> Dict[str, Any]: - return { - "jsonrpc": "2.0", - "id": rpc_id, - "result": result, - } - - -def _jsonrpc_error(rpc_id: Any, code: int, message: str, data: Any = None) -> Dict[str, Any]: - payload: Dict[str, Any] = { - "jsonrpc": "2.0", - "id": rpc_id, - "error": { - "code": int(code), - "message": str(message or "unknown error"), - }, - } - if data is not None: - payload["error"]["data"] = data - return payload - - -def _mcp_tool_result(structured: Dict[str, Any], is_error: bool = False) -> Dict[str, Any]: - return { - "content": [ - { - "type": "text", - "text": json.dumps(structured, ensure_ascii=False), - } - ], - "structuredContent": structured, - "isError": bool(is_error), - } - - -def _topic_mcp_tools() -> List[Dict[str, Any]]: - return [ - { - "name": "topic_list_topics", - "description": "List available topics for the current bot.", - "inputSchema": { - "type": "object", - "properties": { - "include_inactive": {"type": "boolean"}, - }, - "additionalProperties": False, - }, - }, - { - "name": "topic_get_schema", - "description": "Get allowed view schema and optional topic-specific schema.", - "inputSchema": { - "type": "object", - "properties": { - "topic_key": {"type": "string"}, - }, - "additionalProperties": False, - }, - }, - { - "name": "topic_route", - "description": "Route candidate content to a topic and decide if publish is needed.", - "inputSchema": { - "type": "object", - "properties": { - "topic_key": {"type": "string"}, - "title": {"type": "string"}, - "content": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - "is_progress": {"type": "boolean"}, - "is_tool_hint": {"type": "boolean"}, - "source": {"type": "string"}, - }, - "additionalProperties": True, - }, - }, - { - "name": "topic_publish", - "description": "Publish one item into topic feed with dedupe support.", - "inputSchema": { - "type": "object", - "properties": { - "topic_key": {"type": "string"}, - "title": {"type": "string"}, - "content": {"type": "string"}, - "level": {"type": "string"}, - "tags": {"type": "array", "items": {"type": "string"}}, - "view": {"type": "object"}, - "dedupe_key": {"type": "string"}, - "source": {"type": "string"}, - "is_progress": {"type": "boolean"}, - "is_tool_hint": {"type": "boolean"}, - }, - "additionalProperties": True, - }, - }, - ] - - -def _topic_mcp_list_topics(session: Session, bot_id: str, args: Dict[str, Any]) -> Dict[str, Any]: - _ensure_topic_defaults(session, bot_id) - include_inactive = _as_bool(args.get("include_inactive")) or ("include_inactive" not in args) - topics = _list_topics(session, bot_id) - if not include_inactive: - topics = [row for row in topics if bool(row.get("is_active"))] - return { - "bot_id": bot_id, - "topics": topics, - } - - -def _topic_mcp_get_schema(session: Session, bot_id: str, args: Dict[str, Any]) -> Dict[str, Any]: - _ensure_topic_defaults(session, bot_id) - topic_key = _normalize_topic_key(args.get("topic_key")) - topic_payload: Optional[Dict[str, Any]] = None - if topic_key: - row = _topic_get_row(session, bot_id, topic_key) - if row: - topic_payload = _topic_to_dict(row) - return { - "version": "v1", - "view_types": ["markdown", "card", "table", "checklist", "metric", "timeline"], - "topic": topic_payload, - "view_schema": { - "type": "object", - "description": "Declarative view payload only. Scripts and unsafe HTML are not allowed.", - }, - "publish_constraints": { - "level": sorted(list(TOPIC_LEVEL_SET)), - "dedupe_window_seconds": TOPIC_DEDUPE_WINDOW_SECONDS, - }, - } - - -def _topic_mcp_route(session: Session, bot_id: str, args: Dict[str, Any]) -> Dict[str, Any]: - _ensure_topic_defaults(session, bot_id) - filter_reason = _topic_filter_reason(args) - if filter_reason: - return { - "should_publish": False, - "topic_key": None, - "confidence": 1.0, - "reason": filter_reason, - } - title = str(args.get("title") or "").strip() - content = str(args.get("content") or args.get("text") or "").strip() - if not title and not content: - return { - "should_publish": False, - "topic_key": None, - "confidence": 1.0, - "reason": "empty title/content", - } - route = _topic_route_pick(session, bot_id, args, requested_topic_key=str(args.get("topic_key") or "")) - return { - "should_publish": bool(route.get("matched")), - "topic_key": route.get("topic_key"), - "confidence": route.get("confidence"), - "reason": route.get("reason"), - } - - -def _topic_mcp_publish(session: Session, bot_id: str, args: Dict[str, Any]) -> Dict[str, Any]: - return _topic_publish_internal(session, bot_id, args) - - -def _dispatch_topic_mcp_method(session: Session, bot_id: str, method: str, params: Dict[str, Any]) -> Any: - if method == "initialize": - return { - "protocolVersion": TOPIC_MCP_PROTOCOL_VERSION, - "capabilities": { - "tools": {}, - }, - "serverInfo": { - "name": TOPIC_MCP_SERVER_NAME, - "version": "0.1.0", - }, - } - if method in {"notifications/initialized", "initialized"}: - return None - if method == "ping": - return {} - if method == "tools/list": - return { - "tools": _topic_mcp_tools(), - } - if method != "tools/call": - raise KeyError(f"Unknown method: {method}") - - tool_name = str(params.get("name") or "").strip() - arguments = params.get("arguments") - if not isinstance(arguments, dict): - arguments = {} - if tool_name == "topic_list_topics": - return _mcp_tool_result(_topic_mcp_list_topics(session, bot_id, arguments)) - if tool_name == "topic_get_schema": - return _mcp_tool_result(_topic_mcp_get_schema(session, bot_id, arguments)) - if tool_name == "topic_route": - return _mcp_tool_result(_topic_mcp_route(session, bot_id, arguments)) - if tool_name == "topic_publish": - return _mcp_tool_result(_topic_mcp_publish(session, bot_id, arguments)) - return _mcp_tool_result( - { - "error": f"unknown tool: {tool_name}", - "available_tools": [tool["name"] for tool in _topic_mcp_tools()], - }, - is_error=True, - ) - - -def _handle_topic_mcp_rpc_item(session: Session, bot_id: str, item: Any) -> Optional[Dict[str, Any]]: - if not isinstance(item, dict): - return _jsonrpc_error(None, -32600, "Invalid Request") - rpc_id = item.get("id") - method = str(item.get("method") or "").strip() - if not method: - return _jsonrpc_error(rpc_id, -32600, "Invalid Request: method is required") - params = item.get("params") - if params is None: - params = {} - if not isinstance(params, dict): - return _jsonrpc_error(rpc_id, -32602, "Invalid params") - try: - result = _dispatch_topic_mcp_method(session, bot_id, method, params) - except KeyError as exc: - return _jsonrpc_error(rpc_id, -32601, str(exc)) - except ValueError as exc: - return _jsonrpc_error(rpc_id, -32602, str(exc)) - except Exception as exc: - logger.exception("topic_mcp method failed: %s", method) - return _jsonrpc_error(rpc_id, -32000, f"topic_mcp execution failed: {type(exc).__name__}: {exc}") - - if rpc_id is None: - return None - return _jsonrpc_success(rpc_id, result) diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml index c23e2b0..b738680 100644 --- a/docker-compose.prod.yml +++ b/docker-compose.prod.yml @@ -15,6 +15,10 @@ services: APP_PORT: 8000 APP_RELOAD: "false" DATABASE_ECHO: "false" + DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-20} + DATABASE_MAX_OVERFLOW: ${DATABASE_MAX_OVERFLOW:-40} + DATABASE_POOL_TIMEOUT: ${DATABASE_POOL_TIMEOUT:-30} + DATABASE_POOL_RECYCLE: ${DATABASE_POOL_RECYCLE:-1800} UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100} DATA_ROOT: ${HOST_DATA_ROOT} BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} @@ -23,8 +27,8 @@ services: REDIS_URL: ${REDIS_URL:-} REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot} REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} + CHAT_PULL_PAGE_SIZE: ${CHAT_PULL_PAGE_SIZE:-60} PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} - TOPIC_MCP_INTERNAL_URL: ${TOPIC_MCP_INTERNAL_URL:-http://host.docker.internal:8000/api/mcp/topic} STT_ENABLED: ${STT_ENABLED:-true} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} STT_MODEL_DIR: ${STT_MODEL_DIR:-${HOST_DATA_ROOT}/model} diff --git a/frontend/src/hooks/useBotsSync.ts b/frontend/src/hooks/useBotsSync.ts index 8810620..77ebda3 100644 --- a/frontend/src/hooks/useBotsSync.ts +++ b/frontend/src/hooks/useBotsSync.ts @@ -98,11 +98,9 @@ export function useBotsSync(forcedBotId?: string) { const target = String(botId || '').trim(); if (!target) return; try { - const res = await axios.get(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages`, { - params: { limit: 300 }, - }); - const rows = Array.isArray(res.data) ? res.data : []; - const messages: ChatMessage[] = rows + const res = await axios.get<{ items?: any[] }>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`); + const rows = Array.isArray(res.data?.items) ? res.data.items : []; + const latestPage: ChatMessage[] = rows .map((row) => { const roleRaw = String(row?.role || '').toLowerCase(); const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; @@ -117,6 +115,20 @@ export function useBotsSync(forcedBotId?: string) { }) .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) .slice(-300); + + // Keep already lazy-loaded history; only merge/refresh the latest page. + const existing = (activeBotsRef.current[target]?.messages || []).filter((m) => (m.kind || 'final') !== 'progress'); + const mergedMap = new Map(); + [...existing, ...latestPage].forEach((msg) => { + const key = msg.id ? `id:${msg.id}` : `k:${msg.role}:${msg.ts}:${msg.text}`; + if (!mergedMap.has(key)) mergedMap.set(key, msg); + }); + const messages = Array.from(mergedMap.values()) + .sort((a, b) => { + if (a.ts !== b.ts) return a.ts - b.ts; + return Number(a.id || 0) - Number(b.id || 0); + }) + .slice(-300); setBotMessages(target, messages); const lastUser = [...messages].reverse().find((m) => m.role === 'user'); diff --git a/frontend/src/i18n/channels.en.ts b/frontend/src/i18n/channels.en.ts index b7e971a..bfb8a2c 100644 --- a/frontend/src/i18n/channels.en.ts +++ b/frontend/src/i18n/channels.en.ts @@ -32,6 +32,27 @@ export const channelsEn = { botToken: 'Bot Token', appToken: 'App Token', telegramToken: 'Telegram Bot Token', + emailConsentGranted: 'Mailbox consent granted', + emailFromAddress: 'From Address', + emailAllowFrom: 'Allowed Senders', + emailAllowFromPlaceholder: 'One email address per line. Empty means allow all.', + emailImapHost: 'IMAP Host', + emailImapPort: 'IMAP Port', + emailImapUsername: 'IMAP Username', + emailImapPassword: 'IMAP Password', + emailImapMailbox: 'IMAP Mailbox', + emailImapUseSsl: 'Use IMAP SSL', + emailSmtpHost: 'SMTP Host', + emailSmtpPort: 'SMTP Port', + emailSmtpUsername: 'SMTP Username', + emailSmtpPassword: 'SMTP Password', + emailSmtpUseTls: 'Use SMTP TLS', + emailSmtpUseSsl: 'Use SMTP SSL', + emailAutoReplyEnabled: 'Auto reply to inbound emails', + emailPollIntervalSeconds: 'Poll Interval (seconds)', + emailMaxBodyChars: 'Max Body Characters', + emailSubjectPrefix: 'Reply Subject Prefix', + emailMarkSeen: 'Mark as seen after reading', proxy: 'Proxy (optional)', replyToMessage: 'Reply to original message', encryptKey: 'Encrypt Key (optional)', diff --git a/frontend/src/i18n/channels.zh-cn.ts b/frontend/src/i18n/channels.zh-cn.ts index 7f0cdaf..8388dba 100644 --- a/frontend/src/i18n/channels.zh-cn.ts +++ b/frontend/src/i18n/channels.zh-cn.ts @@ -32,6 +32,27 @@ export const channelsZhCn = { botToken: 'Bot Token', appToken: 'App Token', telegramToken: 'Telegram Bot Token', + emailConsentGranted: '已获得邮箱访问授权', + emailFromAddress: '发件人地址', + emailAllowFrom: '允许发件人', + emailAllowFromPlaceholder: '每行一个邮箱地址,留空默认允许全部', + emailImapHost: 'IMAP Host', + emailImapPort: 'IMAP Port', + emailImapUsername: 'IMAP 用户名', + emailImapPassword: 'IMAP 密码', + emailImapMailbox: 'IMAP 邮箱目录', + emailImapUseSsl: 'IMAP 使用 SSL', + emailSmtpHost: 'SMTP Host', + emailSmtpPort: 'SMTP Port', + emailSmtpUsername: 'SMTP 用户名', + emailSmtpPassword: 'SMTP 密码', + emailSmtpUseTls: 'SMTP 使用 TLS', + emailSmtpUseSsl: 'SMTP 使用 SSL', + emailAutoReplyEnabled: '自动回复邮件', + emailPollIntervalSeconds: '轮询间隔(秒)', + emailMaxBodyChars: '正文最大字符数', + emailSubjectPrefix: '回复主题前缀', + emailMarkSeen: '读取后标记已读', proxy: '代理(可选)', replyToMessage: '回复时引用原消息', encryptKey: 'Encrypt Key(可选)', diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index a16fee7..faad7d2 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -204,11 +204,6 @@ export const dashboardEn = { mcpDraftRequired: 'MCP server name and URL are required.', mcpDraftAdded: 'Added to the MCP list. Save config to apply.', addMcpServer: 'Add MCP Server', - topicMcpEnableTitle: 'Enable topic_mcp', - topicMcpEnableConfirm: 'This bot has not configured topic_mcp yet. Enable it now?', - topicMcpEnableAction: 'Enable Now', - topicMcpEnabled: 'topic_mcp enabled.', - topicMcpEnableFail: 'Failed to enable topic_mcp.', saveMcpConfig: 'Save MCP Config', mcpSaved: 'MCP config saved.', mcpSaveFail: 'Failed to save MCP config.', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index 210979e..92823cc 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -204,11 +204,6 @@ export const dashboardZhCn = { mcpDraftRequired: '请先填写 MCP 服务名称和 URL。', mcpDraftAdded: '已加入 MCP 列表,记得保存配置。', addMcpServer: '新增 MCP Server', - topicMcpEnableTitle: '开通 topic_mcp', - topicMcpEnableConfirm: '当前 Bot 尚未配置 topic_mcp。是否立即开通?', - topicMcpEnableAction: '立即开通', - topicMcpEnabled: 'topic_mcp 已开通。', - topicMcpEnableFail: 'topic_mcp 开通失败。', saveMcpConfig: '保存 MCP 配置', mcpSaved: 'MCP 配置已保存。', mcpSaveFail: 'MCP 配置保存失败。', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index e19e17c..0ceaa64 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -30,7 +30,7 @@ interface BotDashboardModuleProps { type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type WorkspaceNodeType = 'dir' | 'file'; -type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack'; +type ChannelType = 'dashboard' | 'feishu' | 'qq' | 'dingtalk' | 'telegram' | 'slack' | 'email'; type RuntimeViewMode = 'visual' | 'topic'; type CompactPanelTab = 'chat' | 'runtime'; type QuotedReply = { id?: number; text: string; ts: number }; @@ -293,6 +293,9 @@ interface SystemDefaultsResponse { workspace?: { download_extensions?: unknown; }; + chat?: { + pull_page_size?: number; + }; topic_presets?: unknown; speech?: { enabled?: boolean; @@ -422,7 +425,7 @@ const providerPresets: Record( DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, ); @@ -1928,13 +1934,6 @@ export function BotDashboardModule({ box.scrollTo({ top: box.scrollHeight, behavior }); }, []); - const onChatScroll = useCallback(() => { - const box = chatScrollRef.current; - if (!box) return; - const distanceToBottom = box.scrollHeight - box.scrollTop - box.clientHeight; - chatAutoFollowRef.current = distanceToBottom <= 64; - }, []); - useEffect(() => { chatAutoFollowRef.current = true; requestAnimationFrame(() => syncChatScrollToBottom('auto')); @@ -2032,6 +2031,10 @@ export function BotDashboardModule({ setVoiceMaxSeconds(normalized); setVoiceCountdown(normalized); } + const pullPageSize = Number(res.data?.chat?.pull_page_size); + if (Number.isFinite(pullPageSize) && pullPageSize > 0) { + setChatPullPageSize(Math.max(10, Math.min(500, Math.floor(pullPageSize)))); + } } catch { // keep default limit } @@ -2435,19 +2438,7 @@ export function BotDashboardModule({ setExpandedMcpByKey({}); setNewMcpPanelOpen(false); resetNewMcpDraft(); - let drafts = await loadBotMcpConfig(botId); - if (!drafts.some((row) => isTopicMcpServerRow(row))) { - const ok = await confirm({ - title: t.topicMcpEnableTitle, - message: t.topicMcpEnableConfirm, - tone: 'warning', - confirmText: t.topicMcpEnableAction, - cancelText: t.cancel, - }); - if (ok) { - drafts = await enableBotTopicMcp(botId); - } - } + await loadBotMcpConfig(botId); setShowMcpModal(true); }; @@ -2839,18 +2830,6 @@ export function BotDashboardModule({ } }; - const enableBotTopicMcp = async (botId: string): Promise => { - try { - const res = await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${botId}/mcp-config/topic-mcp/enable`); - const drafts = mapMcpResponseToDrafts(res.data); - notify(t.topicMcpEnabled, { tone: 'success' }); - return applyMcpDrafts(drafts); - } catch (error: any) { - notify(error?.response?.data?.detail || t.topicMcpEnableFail, { tone: 'error' }); - return []; - } - }; - const saveNewMcpServer = async () => { const name = String(newMcpDraft.name || '').trim(); const url = String(newMcpDraft.url || '').trim(); @@ -2897,22 +2876,14 @@ export function BotDashboardModule({ setMcpTestByIndex((prev) => ({ ...prev, [index]: { status: 'idle', message: '' } })); }; - const isTopicMcpServerRow = (row?: MCPServerDraft | null) => { - if (!row) return false; - const name = String(row.name || row.originName || '').trim().toLowerCase(); - return name === 'topic_mcp'; - }; - const canRemoveMcpServer = (row?: MCPServerDraft | null) => { - if (!row) return false; - if (!row.locked) return true; - return isTopicMcpServerRow(row); + return Boolean(row && !row.locked); }; const removeMcpServer = async (index: number) => { const row = mcpServers[index]; if (!canRemoveMcpServer(row)) { - notify(isZh ? '内置 MCP 服务不可删除。' : 'Built-in MCP server cannot be removed.', { tone: 'warning' }); + notify(isZh ? '当前 MCP 服务不可删除。' : 'This MCP server cannot be removed.', { tone: 'warning' }); return; } const nextRows = mcpServers.filter((_, i) => i !== index); @@ -3217,6 +3188,38 @@ export function BotDashboardModule({ }; const isDashboardChannel = (channel: BotChannel) => String(channel.channel_type).toLowerCase() === 'dashboard'; + const parseChannelListValue = (raw: unknown): string => { + if (!Array.isArray(raw)) return ''; + return raw + .map((item) => String(item || '').trim()) + .filter(Boolean) + .join('\n'); + }; + const parseChannelListInput = (raw: string): string[] => { + const rows: string[] = []; + String(raw || '') + .split(/[\n,]/) + .forEach((item) => { + const text = String(item || '').trim(); + if (text && !rows.includes(text)) rows.push(text); + }); + return rows; + }; + const isChannelConfigured = (channel: BotChannel): boolean => { + const ctype = String(channel.channel_type || '').trim().toLowerCase(); + if (ctype === 'email') { + const extra = channel.extra_config || {}; + return Boolean( + String(extra.imapHost || '').trim() + && String(extra.imapUsername || '').trim() + && String(extra.imapPassword || '').trim() + && String(extra.smtpHost || '').trim() + && String(extra.smtpUsername || '').trim() + && String(extra.smtpPassword || '').trim(), + ); + } + return Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim()); + }; const sanitizeChannelExtra = (channelType: string, extra: Record) => { const type = String(channelType || '').toLowerCase(); if (type === 'dashboard') return extra || {}; @@ -3316,6 +3319,147 @@ export function BotDashboardModule({ ); } + if (ctype === 'email') { + const extra = channel.extra_config || {}; + return ( + <> +
+ +
+
+ + onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })} + autoComplete="off" + /> +
+
+ +