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 # DATABASE_URL=postgresql+psycopg://user:password@127.0.0.1:5432/nanobot_dashboard
# MySQL example: # MySQL example:
# DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard # 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 cache (optional)
REDIS_ENABLED=true REDIS_ENABLED=true
REDIS_URL=redis://127.0.0.1:6379/8 REDIS_URL=redis://127.0.0.1:6379/8
REDIS_PREFIX=dashboard_nanobot REDIS_PREFIX=nanobot
REDIS_DEFAULT_TTL=60 REDIS_DEFAULT_TTL=60
# Chat history page size for upward lazy loading (per request)
CHAT_PULL_PAGE_SIZE=60
# Panel access protection # Panel access protection
PANEL_ACCESS_PASSWORD=change_me_panel_password 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) # Max upload size for backend validation (MB)
UPLOAD_MAX_MB=200 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 # DATABASE_URL=mysql+pymysql://user:password@127.0.0.1:3306/nanobot_dashboard
# Show SQL statements in backend logs (debug only). # Show SQL statements in backend logs (debug only).
DATABASE_ECHO=true DATABASE_ECHO=true
DATABASE_POOL_SIZE=20
DATABASE_MAX_OVERFLOW=40
DATABASE_POOL_TIMEOUT=30
DATABASE_POOL_RECYCLE=1800
# Redis cache # Redis cache
REDIS_ENABLED=false REDIS_ENABLED=false
@ -20,12 +24,11 @@ REDIS_ENABLED=false
REDIS_URL= REDIS_URL=
REDIS_PREFIX=dashboard_nanobot REDIS_PREFIX=dashboard_nanobot
REDIS_DEFAULT_TTL=60 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. # Optional panel-level access password for all backend API/WS calls.
PANEL_ACCESS_PASSWORD= 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) # Max upload size for backend validation (MB)
UPLOAD_MAX_MB=100 UPLOAD_MAX_MB=100
# Workspace files that should use direct download behavior in dashboard # Workspace files that should use direct download behavior in dashboard

View File

@ -2,8 +2,7 @@ import json
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from fastapi import APIRouter, Depends, HTTPException, Request from fastapi import APIRouter, Depends, HTTPException
from fastapi.responses import JSONResponse, Response
from pydantic import BaseModel from pydantic import BaseModel
from sqlalchemy import func from sqlalchemy import func
from sqlmodel import Session, select from sqlmodel import Session, select
@ -12,13 +11,9 @@ from core.database import get_session
from models.bot import BotInstance from models.bot import BotInstance
from models.topic import TopicItem, TopicTopic from models.topic import TopicItem, TopicTopic
from services.topic_service import ( from services.topic_service import (
TOPIC_MCP_TOKEN_HEADER,
_TOPIC_KEY_RE, _TOPIC_KEY_RE,
_handle_topic_mcp_rpc_item,
_jsonrpc_error,
_list_topics, _list_topics,
_normalize_topic_key, _normalize_topic_key,
_resolve_topic_mcp_bot_id_by_token,
_topic_item_to_dict, _topic_item_to_dict,
_topic_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, "bot_id": bot_id,
"item": _topic_item_to_dict(row), "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 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. # Fallback for future custom channels.
channels_cfg[channel_type] = { channels_cfg[channel_type] = {
"enabled": enabled, "enabled": enabled,

View File

@ -1,13 +1,37 @@
from sqlalchemy import inspect, text from sqlalchemy import inspect, text
from sqlmodel import SQLModel, Session, create_engine 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. # Ensure table models are registered in SQLModel metadata before create_all.
from models import bot as _bot_models # noqa: F401 from models import bot as _bot_models # noqa: F401
from models import topic as _topic_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: 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)) 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: def _normalize_extension(raw: str) -> str:
text = str(raw or "").strip().lower() text = str(raw or "").strip().lower()
if not text: if not text:
@ -178,6 +174,10 @@ DATABASE_URL: Final[str] = _normalize_database_url(
DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL) DATABASE_ENGINE: Final[str] = _database_engine(DATABASE_URL)
DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL) DATABASE_URL_DISPLAY: Final[str] = _mask_database_url(DATABASE_URL)
DATABASE_ECHO: Final[bool] = _env_bool("DATABASE_ECHO", True) 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) UPLOAD_MAX_MB: Final[int] = _env_int("UPLOAD_MAX_MB", 100, 1, 2048)
WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = ( WORKSPACE_DOWNLOAD_EXTENSIONS_DEFAULT: Final[tuple[str, ...]] = (
".pdf", ".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_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_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) 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() 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() TEMPLATE_ROOT: Final[Path] = (BACKEND_ROOT / "templates").resolve()
AGENT_MD_TEMPLATES_FILE: Final[Path] = TEMPLATE_ROOT / "agent_md_templates.json" 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 ( from core.settings import (
BOTS_WORKSPACE_ROOT, BOTS_WORKSPACE_ROOT,
CHAT_PULL_PAGE_SIZE,
DATA_ROOT, DATA_ROOT,
DATABASE_ECHO, DATABASE_ECHO,
DATABASE_ENGINE, DATABASE_ENGINE,
@ -50,7 +51,6 @@ from core.settings import (
STT_ENABLED, STT_ENABLED,
STT_MAX_AUDIO_SECONDS, STT_MAX_AUDIO_SECONDS,
STT_MODEL, STT_MODEL,
TOPIC_MCP_INTERNAL_URL,
TOPIC_PRESET_TEMPLATES, TOPIC_PRESET_TEMPLATES,
TOPIC_PRESETS_TEMPLATES_FILE, TOPIC_PRESETS_TEMPLATES_FILE,
UPLOAD_MAX_MB, UPLOAD_MAX_MB,
@ -61,11 +61,6 @@ from core.settings import (
from models.bot import BotInstance, BotMessage, NanobotImage from models.bot import BotInstance, BotMessage, NanobotImage
from models.topic import TopicBotSettings, TopicItem, TopicTopic from models.topic import TopicBotSettings, TopicItem, TopicTopic
from api.topic_router import router as topic_router 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 from services.topic_runtime import publish_runtime_topic_packet
app = FastAPI(title="Dashboard Nanobot API") 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/panel/auth/login",
"/api/health", "/api/health",
"/api/health/cache", "/api/health/cache",
"/api/mcp/topic",
}: }:
return False return False
if _is_bot_panel_management_api_path(raw, verb): 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}" 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: def _cache_key_images() -> str:
return "images:list" return "images:list"
@ -511,7 +510,6 @@ async def on_startup():
print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}") print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}")
print(f"🧠 Redis 缓存: {'enabled' if cache.ping() else 'disabled'} ({REDIS_URL if REDIS_ENABLED else 'not configured'})") 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"🔐 面板访问密码: {'enabled' if str(PANEL_ACCESS_PASSWORD or '').strip() else 'disabled'}")
print(f"🧩 Topic MCP internal URL: {TOPIC_MCP_INTERNAL_URL}")
init_database() init_database()
cache.delete_prefix("") cache.delete_prefix("")
with Session(engine) as session: with Session(engine) as session:
@ -553,6 +551,9 @@ def get_system_defaults():
"workspace": { "workspace": {
"download_extensions": list(WORKSPACE_DOWNLOAD_EXTENSIONS), "download_extensions": list(WORKSPACE_DOWNLOAD_EXTENSIONS),
}, },
"chat": {
"pull_page_size": CHAT_PULL_PAGE_SIZE,
},
"topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES, "topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES,
"speech": { "speech": {
"enabled": STT_ENABLED, "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 "") external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("secret") or "") app_secret = str(cfg.get("secret") or "")
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} 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: else:
external_app_id = str( external_app_id = str(
cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "" 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, "secret": app_secret,
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), "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 = dict(extra)
merged.update( merged.update(
{ {
@ -2374,40 +2422,14 @@ def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)):
if not bot: if not bot:
raise HTTPException(status_code=404, detail="Bot not found") raise HTTPException(status_code=404, detail="Bot not found")
config_data = _read_bot_config(bot_id) 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): if not isinstance(tools_cfg, dict):
tools_cfg = {} tools_cfg = {}
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers")) mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
mcp_servers = _annotate_locked_mcp_servers(mcp_servers)
return { return {
"bot_id": bot_id, "bot_id": bot_id,
"mcp_servers": mcp_servers, "mcp_servers": mcp_servers,
"locked_servers": [TOPIC_MCP_SERVER_NAME] if TOPIC_MCP_SERVER_NAME in mcp_servers else [], "locked_servers": [],
"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 [],
"restart_required": True, "restart_required": True,
} }
@ -2431,7 +2453,7 @@ def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, sessi
return { return {
"status": "updated", "status": "updated",
"bot_id": bot_id, "bot_id": bot_id,
"mcp_servers": _annotate_locked_mcp_servers(mcp_servers), "mcp_servers": mcp_servers,
"locked_servers": [], "locked_servers": [],
"restart_required": True, "restart_required": True,
} }
@ -2448,7 +2470,18 @@ def test_bot_mcp_config(bot_id: str, payload: BotMcpConfigTestRequest, session:
"headers": payload.headers or {}, "headers": payload.headers or {},
"toolTimeout": payload.tool_timeout if payload.tool_timeout is not None else 10, "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") @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 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") @app.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
def update_bot_message_feedback( def update_bot_message_feedback(
bot_id: str, bot_id: str,

View File

@ -3,7 +3,7 @@ from typing import Any, Dict, Optional
from sqlmodel import Session 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 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() packet_type = str(packet.get("type") or "").strip().upper()
if packet_type not in {"ASSISTANT_MESSAGE", "BUS_EVENT"} or not persisted_message_id: if packet_type not in {"ASSISTANT_MESSAGE", "BUS_EVENT"} or not persisted_message_id:
return return
if not _has_topic_mcp_server(bot_id):
return
topic_payload = build_topic_publish_payload( topic_payload = build_topic_publish_payload(
bot_id, bot_id,

View File

@ -1,69 +1,17 @@
import json import json
import logging
import os
import re import re
import secrets
from datetime import datetime from datetime import datetime
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from sqlmodel import Session, select 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 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_DEDUPE_WINDOW_SECONDS = 10 * 60
TOPIC_LEVEL_SET = {"info", "warn", "error", "success"} TOPIC_LEVEL_SET = {"info", "warn", "error", "success"}
_TOPIC_KEY_RE = re.compile(r"^[a-z0-9][a-z0-9_.-]{0,63}$") _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: def _as_bool(value: Any) -> bool:
if isinstance(value, bool): if isinstance(value, bool):
return value return value
@ -71,98 +19,6 @@ def _as_bool(value: Any) -> bool:
return text in {"1", "true", "yes", "on", "y"} 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: def _normalize_topic_key(raw: Any) -> str:
value = str(raw or "").strip().lower() value = str(raw or "").strip().lower()
if not value: if not value:
@ -170,14 +26,6 @@ def _normalize_topic_key(raw: Any) -> str:
return value 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]: def _parse_json_dict(raw: str) -> Dict[str, Any]:
text = str(raw or "").strip() text = str(raw or "").strip()
if not text: 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(), "level": str(row.level or "info").strip().lower(),
"tags": _parse_json_list(row.tags_json or "[]"), "tags": _parse_json_list(row.tags_json or "[]"),
"view": _parse_json_dict(row.view_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 "", "dedupe_key": row.dedupe_key or "",
"is_read": bool(row.is_read), "is_read": bool(row.is_read),
"created_at": row.created_at.isoformat() if row.created_at else None, "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 = payload.get("view")
view_json = json.dumps(view, ensure_ascii=False) if isinstance(view, dict) else None 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() now = datetime.utcnow()
item = TopicItem( item = TopicItem(
bot_id=bot_id, 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), "item": _topic_item_to_dict(item),
"route": route_result, "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_PORT: 8000
APP_RELOAD: "false" APP_RELOAD: "false"
DATABASE_ECHO: "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} UPLOAD_MAX_MB: ${UPLOAD_MAX_MB:-100}
DATA_ROOT: ${HOST_DATA_ROOT} DATA_ROOT: ${HOST_DATA_ROOT}
BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT} BOTS_WORKSPACE_ROOT: ${HOST_BOTS_WORKSPACE_ROOT}
@ -23,8 +27,8 @@ services:
REDIS_URL: ${REDIS_URL:-} REDIS_URL: ${REDIS_URL:-}
REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot} REDIS_PREFIX: ${REDIS_PREFIX:-dashboard_nanobot}
REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60} REDIS_DEFAULT_TTL: ${REDIS_DEFAULT_TTL:-60}
CHAT_PULL_PAGE_SIZE: ${CHAT_PULL_PAGE_SIZE:-60}
PANEL_ACCESS_PASSWORD: ${PANEL_ACCESS_PASSWORD:-} 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_ENABLED: ${STT_ENABLED:-true}
STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin} STT_MODEL: ${STT_MODEL:-ggml-small-q8_0.bin}
STT_MODEL_DIR: ${STT_MODEL_DIR:-${HOST_DATA_ROOT}/model} 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(); const target = String(botId || '').trim();
if (!target) return; if (!target) return;
try { try {
const res = await axios.get<any[]>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages`, { const res = await axios.get<{ items?: any[] }>(`${APP_ENDPOINTS.apiBase}/bots/${target}/messages/page`);
params: { limit: 300 }, const rows = Array.isArray(res.data?.items) ? res.data.items : [];
}); const latestPage: ChatMessage[] = rows
const rows = Array.isArray(res.data) ? res.data : [];
const messages: ChatMessage[] = rows
.map((row) => { .map((row) => {
const roleRaw = String(row?.role || '').toLowerCase(); const roleRaw = String(row?.role || '').toLowerCase();
const role: ChatMessage['role'] = roleRaw === 'user' || roleRaw === 'assistant' || roleRaw === 'system' ? roleRaw : 'assistant'; 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) .filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0)
.slice(-300); .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); setBotMessages(target, messages);
const lastUser = [...messages].reverse().find((m) => m.role === 'user'); const lastUser = [...messages].reverse().find((m) => m.role === 'user');

View File

@ -32,6 +32,27 @@ export const channelsEn = {
botToken: 'Bot Token', botToken: 'Bot Token',
appToken: 'App Token', appToken: 'App Token',
telegramToken: 'Telegram Bot 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)', proxy: 'Proxy (optional)',
replyToMessage: 'Reply to original message', replyToMessage: 'Reply to original message',
encryptKey: 'Encrypt Key (optional)', encryptKey: 'Encrypt Key (optional)',

View File

@ -32,6 +32,27 @@ export const channelsZhCn = {
botToken: 'Bot Token', botToken: 'Bot Token',
appToken: 'App Token', appToken: 'App Token',
telegramToken: 'Telegram Bot 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: '代理(可选)', proxy: '代理(可选)',
replyToMessage: '回复时引用原消息', replyToMessage: '回复时引用原消息',
encryptKey: 'Encrypt Key可选', encryptKey: 'Encrypt Key可选',

View File

@ -204,11 +204,6 @@ export const dashboardEn = {
mcpDraftRequired: 'MCP server name and URL are required.', mcpDraftRequired: 'MCP server name and URL are required.',
mcpDraftAdded: 'Added to the MCP list. Save config to apply.', mcpDraftAdded: 'Added to the MCP list. Save config to apply.',
addMcpServer: 'Add MCP Server', 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', saveMcpConfig: 'Save MCP Config',
mcpSaved: 'MCP config saved.', mcpSaved: 'MCP config saved.',
mcpSaveFail: 'Failed to save MCP config.', mcpSaveFail: 'Failed to save MCP config.',

View File

@ -204,11 +204,6 @@ export const dashboardZhCn = {
mcpDraftRequired: '请先填写 MCP 服务名称和 URL。', mcpDraftRequired: '请先填写 MCP 服务名称和 URL。',
mcpDraftAdded: '已加入 MCP 列表,记得保存配置。', mcpDraftAdded: '已加入 MCP 列表,记得保存配置。',
addMcpServer: '新增 MCP Server', addMcpServer: '新增 MCP Server',
topicMcpEnableTitle: '开通 topic_mcp',
topicMcpEnableConfirm: '当前 Bot 尚未配置 topic_mcp。是否立即开通',
topicMcpEnableAction: '立即开通',
topicMcpEnabled: 'topic_mcp 已开通。',
topicMcpEnableFail: 'topic_mcp 开通失败。',
saveMcpConfig: '保存 MCP 配置', saveMcpConfig: '保存 MCP 配置',
mcpSaved: 'MCP 配置已保存。', mcpSaved: 'MCP 配置已保存。',
mcpSaveFail: 'MCP 配置保存失败。', mcpSaveFail: 'MCP 配置保存失败。',

View File

@ -30,7 +30,7 @@ interface BotDashboardModuleProps {
type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY'; type AgentTab = 'AGENTS' | 'SOUL' | 'USER' | 'TOOLS' | 'IDENTITY';
type WorkspaceNodeType = 'dir' | 'file'; 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 RuntimeViewMode = 'visual' | 'topic';
type CompactPanelTab = 'chat' | 'runtime'; type CompactPanelTab = 'chat' | 'runtime';
type QuotedReply = { id?: number; text: string; ts: number }; type QuotedReply = { id?: number; text: string; ts: number };
@ -293,6 +293,9 @@ interface SystemDefaultsResponse {
workspace?: { workspace?: {
download_extensions?: unknown; download_extensions?: unknown;
}; };
chat?: {
pull_page_size?: number;
};
topic_presets?: unknown; topic_presets?: unknown;
speech?: { speech?: {
enabled?: boolean; 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 RUNTIME_STALE_MS = 45000;
const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']); const SYSTEM_FALLBACK_TOPIC_KEYS = new Set(['inbox']);
@ -1057,6 +1060,9 @@ export function BotDashboardModule({
sendToolHints: false, sendToolHints: false,
}); });
const [uploadMaxMb, setUploadMaxMb] = useState(100); 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[]>( const [workspaceDownloadExtensions, setWorkspaceDownloadExtensions] = useState<string[]>(
DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS, DEFAULT_WORKSPACE_DOWNLOAD_EXTENSIONS,
); );
@ -1928,13 +1934,6 @@ export function BotDashboardModule({
box.scrollTo({ top: box.scrollHeight, behavior }); 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(() => { useEffect(() => {
chatAutoFollowRef.current = true; chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto')); requestAnimationFrame(() => syncChatScrollToBottom('auto'));
@ -2032,6 +2031,10 @@ export function BotDashboardModule({
setVoiceMaxSeconds(normalized); setVoiceMaxSeconds(normalized);
setVoiceCountdown(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 { } catch {
// keep default limit // keep default limit
} }
@ -2435,19 +2438,7 @@ export function BotDashboardModule({
setExpandedMcpByKey({}); setExpandedMcpByKey({});
setNewMcpPanelOpen(false); setNewMcpPanelOpen(false);
resetNewMcpDraft(); resetNewMcpDraft();
let drafts = await loadBotMcpConfig(botId); 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);
}
}
setShowMcpModal(true); 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 saveNewMcpServer = async () => {
const name = String(newMcpDraft.name || '').trim(); const name = String(newMcpDraft.name || '').trim();
const url = String(newMcpDraft.url || '').trim(); const url = String(newMcpDraft.url || '').trim();
@ -2897,22 +2876,14 @@ export function BotDashboardModule({
setMcpTestByIndex((prev) => ({ ...prev, [index]: { status: 'idle', message: '' } })); 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) => { const canRemoveMcpServer = (row?: MCPServerDraft | null) => {
if (!row) return false; return Boolean(row && !row.locked);
if (!row.locked) return true;
return isTopicMcpServerRow(row);
}; };
const removeMcpServer = async (index: number) => { const removeMcpServer = async (index: number) => {
const row = mcpServers[index]; const row = mcpServers[index];
if (!canRemoveMcpServer(row)) { 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; return;
} }
const nextRows = mcpServers.filter((_, i) => i !== index); 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 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 sanitizeChannelExtra = (channelType: string, extra: Record<string, unknown>) => {
const type = String(channelType || '').toLowerCase(); const type = String(channelType || '').toLowerCase();
if (type === 'dashboard') return extra || {}; 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; return null;
}; };
@ -3534,6 +3678,8 @@ export function BotDashboardModule({
if (!payloadText && pendingAttachments.length === 0) return; if (!payloadText && pendingAttachments.length === 0) return;
try { try {
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true })); setSendingByBot((prev) => ({ ...prev, [selectedBot.id]: true }));
const res = await axios.post( const res = await axios.post(
`${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`, `${APP_ENDPOINTS.apiBase}/bots/${selectedBot.id}/command`,
@ -3550,6 +3696,8 @@ export function BotDashboardModule({
ts: Date.now(), ts: Date.now(),
kind: 'final', kind: 'final',
}); });
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
setCommand(''); setCommand('');
setPendingAttachments([]); setPendingAttachments([]);
setQuotedReply(null); setQuotedReply(null);
@ -3560,6 +3708,8 @@ export function BotDashboardModule({
text: t.sendFailMsg(msg), text: t.sendFailMsg(msg),
ts: Date.now(), ts: Date.now(),
}); });
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
notify(msg, { tone: 'error' }); notify(msg, { tone: 'error' });
} finally { } finally {
setSendingByBot((prev) => { 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`, { 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 : []; const rows = Array.isArray(res.data) ? res.data : [];
return rows return rows
@ -3652,8 +3803,111 @@ export function BotDashboardModule({
} as ChatMessage; } as ChatMessage;
}) })
.filter((msg) => msg.text.trim().length > 0 || (msg.attachments || []).length > 0) .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') => { const submitAssistantFeedback = async (message: ChatMessage, feedback: 'up' | 'down') => {
if (!selectedBotId) { if (!selectedBotId) {
@ -3990,6 +4244,8 @@ export function BotDashboardModule({
useEffect(() => { useEffect(() => {
if (!selectedBotId) { if (!selectedBotId) {
setChatHasMore(false);
setChatLoadingMore(false);
setWorkspaceEntries([]); setWorkspaceEntries([]);
setWorkspaceCurrentPath(''); setWorkspaceCurrentPath('');
setWorkspaceParentPath(null); setWorkspaceParentPath(null);
@ -4026,6 +4282,8 @@ export function BotDashboardModule({
setTopicFeedUnreadCount(0); setTopicFeedUnreadCount(0);
return; return;
} }
setChatHasMore(false);
setChatLoadingMore(false);
setTopics([]); setTopics([]);
setExpandedTopicByKey({}); setExpandedTopicByKey({});
setNewTopicPanelOpen(false); setNewTopicPanelOpen(false);
@ -4042,6 +4300,10 @@ export function BotDashboardModule({
const loadAll = async () => { const loadAll = async () => {
try { try {
if (cancelled) return; if (cancelled) return;
const page = await fetchBotMessagesPage(selectedBotId, { limit: chatPullPageSize });
if (cancelled) return;
setBotMessages(selectedBotId, page.items);
setChatHasMore(Boolean(page.hasMore));
await Promise.all([ await Promise.all([
loadWorkspaceTree(selectedBotId, ''), loadWorkspaceTree(selectedBotId, ''),
loadCronJobs(selectedBotId), loadCronJobs(selectedBotId),
@ -4050,6 +4312,8 @@ export function BotDashboardModule({
loadTopics(selectedBotId), loadTopics(selectedBotId),
loadTopicFeedStats(selectedBotId), loadTopicFeedStats(selectedBotId),
]); ]);
chatAutoFollowRef.current = true;
requestAnimationFrame(() => syncChatScrollToBottom('auto'));
} catch (error: any) { } catch (error: any) {
const detail = String(error?.response?.data?.detail || '').trim(); const detail = String(error?.response?.data?.detail || '').trim();
if (!cancelled && detail) { if (!cancelled && detail) {
@ -4062,7 +4326,7 @@ export function BotDashboardModule({
cancelled = true; cancelled = true;
}; };
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [selectedBotId]); }, [selectedBotId, chatPullPageSize, fetchBotMessagesPage, setBotMessages, syncChatScrollToBottom]);
useEffect(() => { useEffect(() => {
if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return; if (!workspaceAutoRefresh || !selectedBotId || selectedBot?.docker_status !== 'RUNNING') return;
@ -5548,7 +5812,7 @@ export function BotDashboardModule({
if (isDashboardChannel(channel)) return null; if (isDashboardChannel(channel)) return null;
const uiKey = channelDraftUiKey(channel, idx); const uiKey = channelDraftUiKey(channel, idx);
const expanded = expandedChannelByKey[uiKey] ?? idx === 0; 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 = [ const summary = [
String(channel.channel_type || '').toUpperCase(), String(channel.channel_type || '').toUpperCase(),
channel.is_active ? lc.enabled : lc.disabled, channel.is_active ? lc.enabled : lc.disabled,
@ -6185,9 +6449,6 @@ export function BotDashboardModule({
<div className="ops-config-card-main"> <div className="ops-config-card-main">
<strong>{row.name || `${t.mcpServer} #${idx + 1}`}</strong> <strong>{row.name || `${t.mcpServer} #${idx + 1}`}</strong>
<div className="ops-config-collapsed-meta">{summary}</div> <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>
<div className="ops-config-card-actions"> <div className="ops-config-card-actions">
<button <button