v0.1.4-p2
parent
86370d5824
commit
069b750441
|
|
@ -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
|
||||
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
164
backend/main.py
164
backend/main.py
|
|
@ -30,6 +30,7 @@ from core.speech_service import (
|
|||
)
|
||||
from core.settings import (
|
||||
BOTS_WORKSPACE_ROOT,
|
||||
CHAT_PULL_PAGE_SIZE,
|
||||
DATA_ROOT,
|
||||
DATABASE_ECHO,
|
||||
DATABASE_ENGINE,
|
||||
|
|
@ -50,7 +51,6 @@ from core.settings import (
|
|||
STT_ENABLED,
|
||||
STT_MAX_AUDIO_SECONDS,
|
||||
STT_MODEL,
|
||||
TOPIC_MCP_INTERNAL_URL,
|
||||
TOPIC_PRESET_TEMPLATES,
|
||||
TOPIC_PRESETS_TEMPLATES_FILE,
|
||||
UPLOAD_MAX_MB,
|
||||
|
|
@ -61,11 +61,6 @@ from core.settings import (
|
|||
from models.bot import BotInstance, BotMessage, NanobotImage
|
||||
from models.topic import TopicBotSettings, TopicItem, TopicTopic
|
||||
from api.topic_router import router as topic_router
|
||||
from services.topic_service import (
|
||||
TOPIC_MCP_SERVER_NAME,
|
||||
_annotate_locked_mcp_servers,
|
||||
_ensure_topic_mcp_server,
|
||||
)
|
||||
from services.topic_runtime import publish_runtime_topic_packet
|
||||
|
||||
app = FastAPI(title="Dashboard Nanobot API")
|
||||
|
|
@ -402,7 +397,6 @@ def _is_panel_protected_api_path(path: str, method: str = "GET") -> bool:
|
|||
"/api/panel/auth/login",
|
||||
"/api/health",
|
||||
"/api/health/cache",
|
||||
"/api/mcp/topic",
|
||||
}:
|
||||
return False
|
||||
if _is_bot_panel_management_api_path(raw, verb):
|
||||
|
|
@ -487,6 +481,11 @@ def _cache_key_bot_messages(bot_id: str, limit: int) -> str:
|
|||
return f"bot:messages:{bot_id}:limit:{limit}"
|
||||
|
||||
|
||||
def _cache_key_bot_messages_page(bot_id: str, limit: int, before_id: Optional[int]) -> str:
|
||||
cursor = str(int(before_id)) if isinstance(before_id, int) and before_id > 0 else "latest"
|
||||
return f"bot:messages:page:{bot_id}:before:{cursor}:limit:{limit}"
|
||||
|
||||
|
||||
def _cache_key_images() -> str:
|
||||
return "images:list"
|
||||
|
||||
|
|
@ -511,7 +510,6 @@ async def on_startup():
|
|||
print(f"📁 数据库连接: {DATABASE_URL_DISPLAY}")
|
||||
print(f"🧠 Redis 缓存: {'enabled' if cache.ping() else 'disabled'} ({REDIS_URL if REDIS_ENABLED else 'not configured'})")
|
||||
print(f"🔐 面板访问密码: {'enabled' if str(PANEL_ACCESS_PASSWORD or '').strip() else 'disabled'}")
|
||||
print(f"🧩 Topic MCP internal URL: {TOPIC_MCP_INTERNAL_URL}")
|
||||
init_database()
|
||||
cache.delete_prefix("")
|
||||
with Session(engine) as session:
|
||||
|
|
@ -553,6 +551,9 @@ def get_system_defaults():
|
|||
"workspace": {
|
||||
"download_extensions": list(WORKSPACE_DOWNLOAD_EXTENSIONS),
|
||||
},
|
||||
"chat": {
|
||||
"pull_page_size": CHAT_PULL_PAGE_SIZE,
|
||||
},
|
||||
"topic_presets": topic_presets.get("presets") or TOPIC_PRESET_TEMPLATES,
|
||||
"speech": {
|
||||
"enabled": STT_ENABLED,
|
||||
|
|
@ -811,6 +812,29 @@ def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Di
|
|||
external_app_id = str(cfg.get("appId") or "")
|
||||
app_secret = str(cfg.get("secret") or "")
|
||||
extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
|
||||
elif ctype == "email":
|
||||
extra = {
|
||||
"consentGranted": bool(cfg.get("consentGranted", False)),
|
||||
"imapHost": str(cfg.get("imapHost") or ""),
|
||||
"imapPort": int(cfg.get("imapPort") or 993),
|
||||
"imapUsername": str(cfg.get("imapUsername") or ""),
|
||||
"imapPassword": str(cfg.get("imapPassword") or ""),
|
||||
"imapMailbox": str(cfg.get("imapMailbox") or "INBOX"),
|
||||
"imapUseSsl": bool(cfg.get("imapUseSsl", True)),
|
||||
"smtpHost": str(cfg.get("smtpHost") or ""),
|
||||
"smtpPort": int(cfg.get("smtpPort") or 587),
|
||||
"smtpUsername": str(cfg.get("smtpUsername") or ""),
|
||||
"smtpPassword": str(cfg.get("smtpPassword") or ""),
|
||||
"smtpUseTls": bool(cfg.get("smtpUseTls", True)),
|
||||
"smtpUseSsl": bool(cfg.get("smtpUseSsl", False)),
|
||||
"fromAddress": str(cfg.get("fromAddress") or ""),
|
||||
"autoReplyEnabled": bool(cfg.get("autoReplyEnabled", True)),
|
||||
"pollIntervalSeconds": int(cfg.get("pollIntervalSeconds") or 30),
|
||||
"markSeen": bool(cfg.get("markSeen", True)),
|
||||
"maxBodyChars": int(cfg.get("maxBodyChars") or 12000),
|
||||
"subjectPrefix": str(cfg.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
|
||||
}
|
||||
else:
|
||||
external_app_id = str(
|
||||
cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or ""
|
||||
|
|
@ -883,6 +907,30 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]:
|
|||
"secret": app_secret,
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
if ctype == "email":
|
||||
return {
|
||||
"enabled": enabled,
|
||||
"consentGranted": bool(extra.get("consentGranted", False)),
|
||||
"imapHost": str(extra.get("imapHost") or ""),
|
||||
"imapPort": max(1, min(int(extra.get("imapPort") or 993), 65535)),
|
||||
"imapUsername": str(extra.get("imapUsername") or ""),
|
||||
"imapPassword": str(extra.get("imapPassword") or ""),
|
||||
"imapMailbox": str(extra.get("imapMailbox") or "INBOX"),
|
||||
"imapUseSsl": bool(extra.get("imapUseSsl", True)),
|
||||
"smtpHost": str(extra.get("smtpHost") or ""),
|
||||
"smtpPort": max(1, min(int(extra.get("smtpPort") or 587), 65535)),
|
||||
"smtpUsername": str(extra.get("smtpUsername") or ""),
|
||||
"smtpPassword": str(extra.get("smtpPassword") or ""),
|
||||
"smtpUseTls": bool(extra.get("smtpUseTls", True)),
|
||||
"smtpUseSsl": bool(extra.get("smtpUseSsl", False)),
|
||||
"fromAddress": str(extra.get("fromAddress") or ""),
|
||||
"autoReplyEnabled": bool(extra.get("autoReplyEnabled", True)),
|
||||
"pollIntervalSeconds": max(5, int(extra.get("pollIntervalSeconds") or 30)),
|
||||
"markSeen": bool(extra.get("markSeen", True)),
|
||||
"maxBodyChars": max(1, int(extra.get("maxBodyChars") or 12000)),
|
||||
"subjectPrefix": str(extra.get("subjectPrefix") or "Re: "),
|
||||
"allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
|
||||
}
|
||||
merged = dict(extra)
|
||||
merged.update(
|
||||
{
|
||||
|
|
@ -2374,40 +2422,14 @@ def get_bot_mcp_config(bot_id: str, session: Session = Depends(get_session)):
|
|||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
config_data = _read_bot_config(bot_id)
|
||||
tools_cfg = config_data.get("tools")
|
||||
tools_cfg = config_data.get("tools") if isinstance(config_data, dict) else {}
|
||||
if not isinstance(tools_cfg, dict):
|
||||
tools_cfg = {}
|
||||
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||
mcp_servers = _annotate_locked_mcp_servers(mcp_servers)
|
||||
return {
|
||||
"bot_id": bot_id,
|
||||
"mcp_servers": mcp_servers,
|
||||
"locked_servers": [TOPIC_MCP_SERVER_NAME] if TOPIC_MCP_SERVER_NAME in mcp_servers else [],
|
||||
"restart_required": True,
|
||||
}
|
||||
|
||||
|
||||
@app.post("/api/bots/{bot_id}/mcp-config/topic-mcp/enable")
|
||||
def enable_bot_topic_mcp(bot_id: str, session: Session = Depends(get_session)):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
config_data = _read_bot_config(bot_id)
|
||||
if not isinstance(config_data, dict):
|
||||
config_data = {}
|
||||
_ensure_topic_mcp_server(bot_id, config_data=config_data, persist=True)
|
||||
config_data = _read_bot_config(bot_id)
|
||||
tools_cfg = config_data.get("tools")
|
||||
if not isinstance(tools_cfg, dict):
|
||||
tools_cfg = {}
|
||||
mcp_servers = _normalize_mcp_servers(tools_cfg.get("mcpServers"))
|
||||
mcp_servers = _annotate_locked_mcp_servers(mcp_servers)
|
||||
_invalidate_bot_detail_cache(bot_id)
|
||||
return {
|
||||
"status": "enabled",
|
||||
"bot_id": bot_id,
|
||||
"mcp_servers": mcp_servers,
|
||||
"locked_servers": [TOPIC_MCP_SERVER_NAME] if TOPIC_MCP_SERVER_NAME in mcp_servers else [],
|
||||
"locked_servers": [],
|
||||
"restart_required": True,
|
||||
}
|
||||
|
||||
|
|
@ -2431,7 +2453,7 @@ def update_bot_mcp_config(bot_id: str, payload: BotMcpConfigUpdateRequest, sessi
|
|||
return {
|
||||
"status": "updated",
|
||||
"bot_id": bot_id,
|
||||
"mcp_servers": _annotate_locked_mcp_servers(mcp_servers),
|
||||
"mcp_servers": mcp_servers,
|
||||
"locked_servers": [],
|
||||
"restart_required": True,
|
||||
}
|
||||
|
|
@ -2448,7 +2470,18 @@ def test_bot_mcp_config(bot_id: str, payload: BotMcpConfigTestRequest, session:
|
|||
"headers": payload.headers or {},
|
||||
"toolTimeout": payload.tool_timeout if payload.tool_timeout is not None else 10,
|
||||
}
|
||||
return _probe_mcp_server(cfg, bot_id=bot_id)
|
||||
result = _probe_mcp_server(cfg, bot_id=bot_id)
|
||||
if not result.get("ok"):
|
||||
logger.error(
|
||||
"mcp probe failed bot_id=%s transport=%s url=%s probe_from=%s status_code=%s message=%s",
|
||||
bot_id,
|
||||
result.get("transport"),
|
||||
cfg.get("url"),
|
||||
result.get("probe_from"),
|
||||
result.get("status_code"),
|
||||
result.get("message"),
|
||||
)
|
||||
return result
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/env-params")
|
||||
|
|
@ -2895,6 +2928,61 @@ def list_bot_messages(bot_id: str, limit: int = 200, session: Session = Depends(
|
|||
return payload
|
||||
|
||||
|
||||
@app.get("/api/bots/{bot_id}/messages/page")
|
||||
def list_bot_messages_page(
|
||||
bot_id: str,
|
||||
limit: int = CHAT_PULL_PAGE_SIZE,
|
||||
before_id: Optional[int] = None,
|
||||
session: Session = Depends(get_session),
|
||||
):
|
||||
bot = session.get(BotInstance, bot_id)
|
||||
if not bot:
|
||||
raise HTTPException(status_code=404, detail="Bot not found")
|
||||
|
||||
safe_limit = max(1, min(int(limit), 500))
|
||||
safe_before_id = int(before_id) if isinstance(before_id, int) and before_id > 0 else None
|
||||
cache_key = _cache_key_bot_messages_page(bot_id, safe_limit, safe_before_id)
|
||||
cached = cache.get_json(cache_key)
|
||||
if isinstance(cached, dict) and isinstance(cached.get("items"), list):
|
||||
return cached
|
||||
|
||||
stmt = (
|
||||
select(BotMessage)
|
||||
.where(BotMessage.bot_id == bot_id)
|
||||
.order_by(BotMessage.created_at.desc(), BotMessage.id.desc())
|
||||
.limit(safe_limit + 1)
|
||||
)
|
||||
if safe_before_id is not None:
|
||||
stmt = stmt.where(BotMessage.id < safe_before_id)
|
||||
|
||||
rows = session.exec(stmt).all()
|
||||
has_more = len(rows) > safe_limit
|
||||
if has_more:
|
||||
rows = rows[:safe_limit]
|
||||
ordered = list(reversed(rows))
|
||||
items = [
|
||||
{
|
||||
"id": row.id,
|
||||
"bot_id": row.bot_id,
|
||||
"role": row.role,
|
||||
"text": row.text,
|
||||
"media": _parse_message_media(bot_id, getattr(row, "media_json", None)),
|
||||
"feedback": str(getattr(row, "feedback", "") or "").strip() or None,
|
||||
"ts": int(row.created_at.timestamp() * 1000),
|
||||
}
|
||||
for row in ordered
|
||||
]
|
||||
next_before_id = rows[-1].id if rows else None
|
||||
payload = {
|
||||
"items": items,
|
||||
"has_more": bool(has_more),
|
||||
"next_before_id": next_before_id,
|
||||
"limit": safe_limit,
|
||||
}
|
||||
cache.set_json(cache_key, payload, ttl=30)
|
||||
return payload
|
||||
|
||||
|
||||
@app.put("/api/bots/{bot_id}/messages/{message_id}/feedback")
|
||||
def update_bot_message_feedback(
|
||||
bot_id: str,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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');
|
||||
|
|
|
|||
|
|
@ -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)',
|
||||
|
|
|
|||
|
|
@ -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(可选)',
|
||||
|
|
|
|||
|
|
@ -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.',
|
||||
|
|
|
|||
|
|
@ -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 配置保存失败。',
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
Loading…
Reference in New Issue