v0.1.4-p2

main
mula.liu 2026-03-14 15:44:11 +08:00
parent 86370d5824
commit 069b750441
16 changed files with 580 additions and 574 deletions

View File

@ -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

View File

@ -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

View File

@ -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)

View File

@ -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,

View File

@ -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:

View File

@ -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"

View File

@ -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,

View File

@ -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,

View File

@ -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)

View File

@ -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}

View File

@ -98,11 +98,9 @@ export function useBotsSync(forcedBotId?: string) {
const target = String(botId || '').trim();
if (!target) return;
try {
const res = await axios.get<any[]>(`${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<string, ChatMessage>();
[...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');

View File

@ -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)',

View File

@ -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可选',

View File

@ -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.',

View File

@ -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 配置保存失败。',

View File

@ -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<string, { model: string; apiBase?: string; note: {
},
},
};
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack'];
const optionalChannelTypes: ChannelType[] = ['feishu', 'qq', 'dingtalk', 'telegram', 'slack', 'email'];
const RUNTIME_STALE_MS = 45000;
const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
@ -1057,6 +1060,9 @@ export function BotDashboardModule({
sendToolHints: false,
});
const [uploadMaxMb, setUploadMaxMb] = useState(100);
const [chatPullPageSize, setChatPullPageSize] = useState(60);
const [chatHasMore, setChatHasMore] = useState(false);
const [chatLoadingMore, setChatLoadingMore] = useState(false);
const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
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<MCPServerDraft[]> => {
try {
const res = await axios.post<MCPConfigResponse>(`${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<string, unknown>) => {
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 (
<>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.consentGranted)}
onChange={(e) => onPatch({ extra_config: { ...extra, consentGranted: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailConsentGranted}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailFromAddress}</label>
<input
className="input"
value={String(extra.fromAddress || '')}
onChange={(e) => onPatch({ extra_config: { ...extra, fromAddress: e.target.value } })}
autoComplete="off"
/>
</div>
<div className="ops-config-field ops-config-field-full">
<label className="field-label">{lc.emailAllowFrom}</label>
<textarea
className="textarea"
rows={3}
value={parseChannelListValue(extra.allowFrom)}
onChange={(e) => onPatch({ extra_config: { ...extra, allowFrom: parseChannelListInput(e.target.value) } })}
placeholder={lc.emailAllowFromPlaceholder}
/>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapHost}</label>
<input className="input" value={String(extra.imapHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.imapPort ?? 993)} onChange={(e) => onPatch({ extra_config: { ...extra, imapPort: Number(e.target.value || 993) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapUsername}</label>
<input className="input" value={String(extra.imapUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapPassword}</label>
<input className="input" type="password" value={String(extra.imapPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, imapPassword: e.target.value } })} autoComplete="new-password" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailImapMailbox}</label>
<input className="input" value={String(extra.imapMailbox || 'INBOX')} onChange={(e) => onPatch({ extra_config: { ...extra, imapMailbox: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.imapUseSsl ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, imapUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailImapUseSsl}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpHost}</label>
<input className="input" value={String(extra.smtpHost || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpHost: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPort}</label>
<input className="input mono" type="number" min="1" max="65535" value={String(extra.smtpPort ?? 587)} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPort: Number(e.target.value || 587) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpUsername}</label>
<input className="input" value={String(extra.smtpUsername || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpUsername: e.target.value } })} autoComplete="username" />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSmtpPassword}</label>
<input className="input" type="password" value={String(extra.smtpPassword || '')} onChange={(e) => onPatch({ extra_config: { ...extra, smtpPassword: e.target.value } })} autoComplete="new-password" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseTls ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseTls: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailSmtpUseTls}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.smtpUseSsl)}
onChange={(e) => onPatch({ extra_config: { ...extra, smtpUseSsl: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailSmtpUseSsl}
</label>
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.autoReplyEnabled ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, autoReplyEnabled: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailAutoReplyEnabled}
</label>
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailPollIntervalSeconds}</label>
<input className="input mono" type="number" min="5" max="3600" value={String(extra.pollIntervalSeconds ?? 30)} onChange={(e) => onPatch({ extra_config: { ...extra, pollIntervalSeconds: Number(e.target.value || 30) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailMaxBodyChars}</label>
<input className="input mono" type="number" min="1" max="50000" value={String(extra.maxBodyChars ?? 12000)} onChange={(e) => onPatch({ extra_config: { ...extra, maxBodyChars: Number(e.target.value || 12000) } })} />
</div>
<div className="ops-config-field">
<label className="field-label">{lc.emailSubjectPrefix}</label>
<input className="input" value={String(extra.subjectPrefix || 'Re: ')} onChange={(e) => onPatch({ extra_config: { ...extra, subjectPrefix: e.target.value } })} autoComplete="off" />
</div>
<div className="ops-config-field" style={{ alignSelf: 'end' }}>
<label className="field-label">
<input
type="checkbox"
checked={Boolean(extra.markSeen ?? true)}
onChange={(e) => onPatch({ extra_config: { ...extra, markSeen: e.target.checked } })}
style={{ marginRight: 6 }}
/>
{lc.emailMarkSeen}
</label>
</div>
</>
);
}
return null;
};
@ -3534,6 +3678,8 @@ export function BotDashboardModule({
if (!payloadText && pendingAttachments.length === 0) return;
try {
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
@ -3550,6 +3696,8 @@ export function BotDashboardModule({
ts: Date.now(),
kind: 'final',
});
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
setCommand('');
setPendingAttachments([]);
setQuotedReply(null);
@ -3560,6 +3708,8 @@ export function BotDashboardModule({
text: t.sendFailMsg(msg),
ts: Date.now(),
});
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
notify(msg, { tone: 'error' });
} finally {
setSendingByBot((prev) => {
@ -3629,9 +3779,10 @@ export function BotDashboardModule({
});
};
const fetchBotMessages = async (botId: string): Promise<ChatMessage[]> => {
const fetchBotMessages = useCallback(async (botId: string): Promise<ChatMessage[]> => {
const safeLimit = Math.max(100, Math.min(500, chatPullPageSize * 3));
const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages`, {
params: { limit: 300 },
params: { limit: safeLimit },
});
const rows = Array.isArray(res.data) ? res.data : [];
return rows
@ -3652,8 +3803,111 @@ export function BotDashboardModule({
} as ChatMessage;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.slice(-300);
.slice(-safeLimit);
}, [chatPullPageSize]);
const fetchBotMessagesPage = useCallback(async (
botId: string,
options?: { beforeId?: number | null; limit?: number },
): Promise<{ items: ChatMessage[]; hasMore: boolean; nextBeforeId: number | null }> => {
const requested = Number(options?.limit);
const safeLimit = Number.isFinite(requested)
? Math.max(10, Math.min(500, Math.floor(requested)))
: Math.max(10, Math.min(500, chatPullPageSize));
const beforeIdRaw = Number(options?.beforeId);
const beforeId = Number.isFinite(beforeIdRaw) && beforeIdRaw > 0 ? Math.floor(beforeIdRaw) : undefined;
const res = await axios.get<{ items?: any[]; has_more?: boolean; next_before_id?: number | null }>(
`${APP_ENDPOINTS.apiBase}/bots/${botId}/messages/page`,
{
params: {
limit: safeLimit,
...(beforeId ? { before_id: beforeId } : {}),
},
},
);
const rows = Array.isArray(res.data?.items) ? res.data.items : [];
const items = rows
.map((row) => {
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;
})
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0);
const nextBeforeRaw = Number(res.data?.next_before_id);
const nextBeforeId = Number.isFinite(nextBeforeRaw) && nextBeforeRaw > 0 ? Math.floor(nextBeforeRaw) : null;
return {
items,
hasMore: Boolean(res.data?.has_more),
nextBeforeId,
};
}, [chatPullPageSize]);
const loadMoreChatMessages = useCallback(async () => {
if (!selectedBotId || chatLoadingMore || !chatHasMore) return;
const current = (activeBots[selectedBotId]?.messages || []).filter((msg) => (msg.kind || 'final') !== 'progress');
const oldestMessageId = current
.map((msg) => Number(msg.id))
.filter((id) => Number.isFinite(id) && id > 0)
.reduce<number | null>((acc, id) => (acc === null ? id : Math.min(acc, id)), null);
if (!oldestMessageId) {
setChatHasMore(false);
return;
}
const scrollBox = chatScrollRef.current;
const prevHeight = scrollBox?.scrollHeight || 0;
const prevTop = scrollBox?.scrollTop || 0;
setChatLoadingMore(true);
try {
const page = await fetchBotMessagesPage(selectedBotId, { beforeId: oldestMessageId, limit: chatPullPageSize });
if (page.items.length <= 0) {
setChatHasMore(false);
return;
}
const mergedMap = new Map<string, ChatMessage>();
[...page.items, ...current].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 merged = 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);
});
setBotMessages(selectedBotId, merged);
setChatHasMore(Boolean(page.hasMore));
requestAnimationFrame(() => {
const box = chatScrollRef.current;
if (!box) return;
const delta = box.scrollHeight - prevHeight;
box.scrollTop = prevTop + Math.max(0, delta);
});
} catch {
// ignore
} finally {
setChatLoadingMore(false);
}
}, [selectedBotId, chatLoadingMore, chatHasMore, activeBots, fetchBotMessagesPage, chatPullPageSize, setBotMessages]);
const onChatScroll = useCallback(() => {
const box = chatScrollRef.current;
if (!box) return;
const distanceToBottom = box.scrollHeight - box.scrollTop - box.clientHeight;
chatAutoFollowRef.current = distanceToBottom <= 64;
if (box.scrollTop <= 28 && chatHasMore && !chatLoadingMore) {
void loadMoreChatMessages();
}
}, [chatHasMore, chatLoadingMore, loadMoreChatMessages]);
const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) {
@ -3990,6 +4244,8 @@ export function BotDashboardModule({
useEffect(() => {
if (!selectedBotId) {
setChatHasMore(false);
setChatLoadingMore(false);
setWorkspaceEntries([]);
setWorkspaceCurrentPath('');
setWorkspaceParentPath(null);
@ -4026,6 +4282,8 @@ export function BotDashboardModule({
setTopicFeedUnreadCount(0);
return;
}
setChatHasMore(false);
setChatLoadingMore(false);
setTopics([]);
setExpandedTopicByKey({});
setNewTopicPanelOpen(false);
@ -4042,6 +4300,10 @@ export function BotDashboardModule({
const loadAll = async () => {
try {
if (cancelled) return;
const page = await fetchBotMessagesPage(selectedBotId, { limit: chatPullPageSize });
if (cancelled) return;
setBotMessages(selectedBotId, page.items);
setChatHasMore(Boolean(page.hasMore));
await Promise.all([
loadWorkspaceTree(selectedBotId, ''),
loadCronJobs(selectedBotId),
@ -4050,6 +4312,8 @@ export function BotDashboardModule({
loadTopics(selectedBotId),
loadTopicFeedStats(selectedBotId),
]);
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
} catch (error: any) {
const detail = String(error?.response?.data?.detail || '').trim();
if (!cancelled && detail) {
@ -4062,7 +4326,7 @@ export function BotDashboardModule({
cancelled = true;
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId]);
}, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]);
useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
@ -5548,7 +5812,7 @@ export function BotDashboardModule({
if (isDashboardChannel(channel)) return null;
const uiKey = channelDraftUiKey(channel, idx);
const expanded = expandedChannelByKey[uiKey] ?? idx === 0;
const hasCredential = Boolean(String(channel.external_app_id || '').trim() || String(channel.app_secret || '').trim());
const hasCredential = isChannelConfigured(channel);
const summary = [
String(channel.channel_type || '').toUpperCase(),
channel.is_active ? lc.enabled : lc.disabled,
@ -6185,9 +6449,6 @@ export function BotDashboardModule({
<div className="ops-config-card-main">
<strong>{row.name || `${t.mcpServer} #${idx + 1}`}</strong>
<div className="ops-config-collapsed-meta">{summary}</div>
{row.locked ? (
<div className="field-label">{isZh ? '内置 topic_mcp 服务(只读,可删除后重新开通)' : 'Built-in topic_mcp server (read-only, removable for reprovision)'}</div>
) : null}
</div>
<div className="ops-config-card-actions">
<button