diff --git a/backend/core/database.py b/backend/core/database.py index 58869f3..3b92bc6 100644 --- a/backend/core/database.py +++ b/backend/core/database.py @@ -35,21 +35,54 @@ engine = create_engine(DATABASE_URL, **_engine_kwargs) def _ensure_botinstance_columns() -> None: - if engine.dialect.name != "sqlite": - return + dialect = engine.dialect.name required_columns = { - "current_state": "TEXT DEFAULT 'IDLE'", - "last_action": "TEXT", - "image_tag": "TEXT DEFAULT 'nanobot-base:v0.1.4'", - "access_password": "TEXT DEFAULT ''", + "current_state": { + "sqlite": "TEXT DEFAULT 'IDLE'", + "postgresql": "TEXT DEFAULT 'IDLE'", + "mysql": "VARCHAR(64) DEFAULT 'IDLE'", + }, + "last_action": { + "sqlite": "TEXT", + "postgresql": "TEXT", + "mysql": "LONGTEXT", + }, + "image_tag": { + "sqlite": "TEXT DEFAULT 'nanobot-base:v0.1.4'", + "postgresql": "TEXT DEFAULT 'nanobot-base:v0.1.4'", + "mysql": "VARCHAR(255) DEFAULT 'nanobot-base:v0.1.4'", + }, + "access_password": { + "sqlite": "TEXT DEFAULT ''", + "postgresql": "TEXT DEFAULT ''", + "mysql": "VARCHAR(255) DEFAULT ''", + }, + "enabled": { + "sqlite": "INTEGER NOT NULL DEFAULT 1", + "postgresql": "BOOLEAN NOT NULL DEFAULT TRUE", + "mysql": "BOOLEAN NOT NULL DEFAULT TRUE", + }, } + + inspector = inspect(engine) + if not inspector.has_table("botinstance"): + return with engine.connect() as conn: - existing_rows = conn.execute(text("PRAGMA table_info(botinstance)")).fetchall() - existing = {str(row[1]) for row in existing_rows} - for col, ddl in required_columns.items(): + existing = { + str(row.get("name")) + for row in inspect(conn).get_columns("botinstance") + if row.get("name") + } + for col, ddl_map in required_columns.items(): if col in existing: continue + ddl = ddl_map.get(dialect) or ddl_map.get("sqlite") conn.execute(text(f"ALTER TABLE botinstance ADD COLUMN {col} {ddl}")) + if "enabled" in existing: + if dialect == "sqlite": + conn.execute(text("UPDATE botinstance SET enabled = 1 WHERE enabled IS NULL")) + else: + conn.execute(text("UPDATE botinstance SET enabled = TRUE WHERE enabled IS NULL")) conn.commit() diff --git a/backend/main.py b/backend/main.py index 12556d2..42f0c9c 100644 --- a/backend/main.py +++ b/backend/main.py @@ -104,6 +104,7 @@ class ChannelConfigUpdateRequest(BaseModel): class BotCreateRequest(BaseModel): id: str name: str + enabled: Optional[bool] = True access_password: Optional[str] = None llm_provider: str llm_model: str @@ -131,6 +132,7 @@ class BotCreateRequest(BaseModel): class BotUpdateRequest(BaseModel): name: Optional[str] = None + enabled: Optional[bool] = None access_password: Optional[str] = None llm_provider: Optional[str] = None llm_model: Optional[str] = None @@ -411,11 +413,24 @@ def _is_bot_panel_management_api_path(path: str, method: str = "GET") -> bool: return ( raw.endswith("/start") or raw.endswith("/stop") + or raw.endswith("/enable") + or raw.endswith("/disable") or raw.endswith("/deactivate") or (verb in {"PUT", "DELETE"} and raw == f"/api/bots/{bot_id}") ) +def _is_bot_enable_api_path(path: str, method: str = "GET") -> bool: + raw = str(path or "").strip() + verb = str(method or "GET").strip().upper() + if verb != "POST": + return False + bot_id = _extract_bot_id_from_api_path(raw) + if not bot_id: + return False + return raw == f"/api/bots/{bot_id}/enable" + + @app.middleware("http") async def bot_access_password_guard(request: Request, call_next): if request.method.upper() == "OPTIONS": @@ -434,6 +449,13 @@ async def bot_access_password_guard(request: Request, call_next): bot = session.get(BotInstance, bot_id) if not bot: return JSONResponse(status_code=404, content={"detail": "Bot not found"}) + enabled = bool(getattr(bot, "enabled", True)) + if not enabled: + is_enable_api = _is_bot_enable_api_path(request.url.path, request.method) + is_read_api = request.method.upper() == "GET" + is_auth_login = request.method.upper() == "POST" and request.url.path == f"/api/bots/{bot_id}/auth/login" + if not (is_enable_api or is_read_api or is_auth_login): + return JSONResponse(status_code=403, content={"detail": "Bot is disabled. Enable it first."}) return await call_next(request) @@ -1215,6 +1237,7 @@ def _serialize_bot(bot: BotInstance) -> Dict[str, Any]: return { "id": bot.id, "name": bot.name, + "enabled": bool(getattr(bot, "enabled", True)), "access_password": bot.access_password or "", "has_access_password": bool(str(bot.access_password or "").strip()), "avatar_model": "base", @@ -1250,6 +1273,7 @@ def _serialize_bot_list_item(bot: BotInstance) -> Dict[str, Any]: return { "id": bot.id, "name": bot.name, + "enabled": bool(getattr(bot, "enabled", True)), "has_access_password": bool(str(bot.access_password or "").strip()), "image_tag": bot.image_tag, "docker_status": bot.docker_status, @@ -1847,6 +1871,7 @@ def create_bot(payload: BotCreateRequest, session: Session = Depends(get_session bot = BotInstance( id=normalized_bot_id, name=payload.name, + enabled=bool(payload.enabled) if payload.enabled is not None else True, access_password=str(payload.access_password or ""), image_tag=payload.image_tag, workspace_dir=os.path.join(BOTS_WORKSPACE_ROOT, normalized_bot_id), @@ -2059,7 +2084,7 @@ def update_bot(bot_id: str, payload: BotUpdateRequest, session: Session = Depend ) runtime_overrides.update(normalized_resources) - db_fields = {"name", "image_tag", "access_password"} + db_fields = {"name", "image_tag", "access_password", "enabled"} for key, value in update_data.items(): if key in db_fields: setattr(bot, key, value) @@ -2093,6 +2118,8 @@ async def start_bot(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") + if not bool(getattr(bot, "enabled", True)): + raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") _sync_workspace_channels(session, bot_id) runtime_snapshot = _read_bot_runtime_snapshot(bot) env_params = _read_env_store(bot_id) @@ -2132,6 +2159,8 @@ def stop_bot(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") + if not bool(getattr(bot, "enabled", True)): + raise HTTPException(status_code=403, detail="Bot is disabled. Enable it first.") docker_manager.stop_bot(bot_id) bot.docker_status = "STOPPED" @@ -2141,6 +2170,36 @@ def stop_bot(bot_id: str, session: Session = Depends(get_session)): return {"status": "stopped"} +@app.post("/api/bots/{bot_id}/enable") +def enable_bot(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") + + bot.enabled = True + session.add(bot) + session.commit() + _invalidate_bot_detail_cache(bot_id) + return {"status": "enabled", "enabled": True} + + +@app.post("/api/bots/{bot_id}/disable") +def disable_bot(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") + + docker_manager.stop_bot(bot_id) + bot.enabled = False + bot.docker_status = "STOPPED" + if str(bot.current_state or "").upper() not in {"ERROR"}: + bot.current_state = "IDLE" + session.add(bot) + session.commit() + _invalidate_bot_detail_cache(bot_id) + return {"status": "disabled", "enabled": False} + + @app.post("/api/bots/{bot_id}/deactivate") def deactivate_bot(bot_id: str, session: Session = Depends(get_session)): bot = session.get(BotInstance, bot_id) @@ -2148,7 +2207,10 @@ def deactivate_bot(bot_id: str, session: Session = Depends(get_session)): raise HTTPException(status_code=404, detail="Bot not found") docker_manager.stop_bot(bot_id) + bot.enabled = False bot.docker_status = "STOPPED" + if str(bot.current_state or "").upper() not in {"ERROR"}: + bot.current_state = "IDLE" session.add(bot) session.commit() _invalidate_bot_detail_cache(bot_id) diff --git a/backend/models/bot.py b/backend/models/bot.py index 1c99b7f..8d9cdd6 100644 --- a/backend/models/bot.py +++ b/backend/models/bot.py @@ -5,6 +5,7 @@ from datetime import datetime class BotInstance(SQLModel, table=True): id: str = Field(primary_key=True) name: str + enabled: bool = Field(default=True, index=True) access_password: str = Field(default="") workspace_dir: str = Field(unique=True) docker_status: str = Field(default="STOPPED", index=True) diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index 56ad013..e139b3a 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -99,8 +99,16 @@ export const dashboardEn = { templateTopicInvalid: 'Invalid topic preset JSON.', templateParseFail: 'Template JSON parse failed.', image: 'Image', + disabled: 'Disabled', stop: 'Stop', start: 'Start', + enable: 'Enable', + disable: 'Disable', + disableConfirm: (id: string) => `Disable bot ${id}? It will be stopped and locked from operations.`, + enableDone: 'Bot enabled.', + disableDone: 'Bot disabled and stopped.', + enableFail: 'Enable failed. Check backend logs.', + disableFail: 'Disable failed. Check backend logs.', restart: 'Restart Bot', restartConfirm: (id: string) => `Restart bot ${id}?`, restartFail: 'Restart failed. Check backend logs.', @@ -122,6 +130,7 @@ export const dashboardEn = { interruptSent: 'Interrupt command sent.', botStarting: 'Bot is starting...', botStopping: 'Bot is stopping...', + botDisabledHint: 'Bot is disabled. Enable it before operating.', chatDisabled: 'Bot is stopped. Chat area is disabled.', selectBot: 'Select a bot to inspect', runtime: 'Runtime Status', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index beca947..b632ed5 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -99,8 +99,16 @@ export const dashboardZhCn = { templateTopicInvalid: '主题模板格式错误。', templateParseFail: '模板 JSON 解析失败。', image: '镜像', + disabled: '已禁用', stop: '停止', start: '启动', + enable: '启用', + disable: '禁用', + disableConfirm: (id: string) => `确认禁用 Bot ${id}?禁用后将立即停止且不可操作。`, + enableDone: 'Bot 已启用。', + disableDone: 'Bot 已禁用并停止。', + enableFail: '启用失败,请查看后端日志。', + disableFail: '禁用失败,请查看后端日志。', restart: '重启 Bot', restartConfirm: (id: string) => `确认重启 Bot ${id}?`, restartFail: '重启失败,请查看后端日志。', @@ -122,6 +130,7 @@ export const dashboardZhCn = { interruptSent: '已发送中断指令。', botStarting: 'Bot 正在启动中...', botStopping: 'Bot 正在停止中...', + botDisabledHint: 'Bot 已禁用,请先启用后再进行操作。', chatDisabled: 'Bot 已停止,对话区已禁用。', selectBot: '请选择 Bot 查看详情', runtime: '运行状态', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 8c37a28..e768f67 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -4,11 +4,13 @@ display: grid; grid-template-rows: auto auto minmax(0, 1fr) auto; gap: 8px; + padding-top: 8px; } .ops-bot-list .list-scroll { min-height: 0; overflow: auto; + padding-top: 4px; padding-right: 2px; max-height: 72vh; } @@ -207,7 +209,7 @@ position: relative; border: 1px solid var(--line); border-radius: 12px; - background: var(--panel-soft); + background: linear-gradient(145deg, color-mix(in oklab, var(--panel-soft) 86%, var(--panel) 14%), color-mix(in oklab, var(--panel-soft) 94%, transparent 6%)); padding: 10px 10px 10px 14px; margin-bottom: 10px; cursor: pointer; @@ -215,18 +217,42 @@ } .ops-bot-card:hover { - border-color: var(--brand); + border-color: color-mix(in oklab, var(--brand) 58%, var(--line) 42%); transform: translateY(-1px); + box-shadow: 0 8px 18px color-mix(in oklab, var(--brand) 14%, transparent); } .ops-bot-card.is-active { - border-color: var(--brand); - box-shadow: 0 10px 24px color-mix(in oklab, var(--brand) 22%, transparent), inset 0 0 0 1px var(--brand); - background: color-mix(in oklab, var(--panel-soft) 76%, var(--brand-soft) 24%); - transform: translateY(-1px); + border-color: color-mix(in oklab, var(--brand) 80%, var(--line) 20%); + box-shadow: + 0 0 0 2px color-mix(in oklab, var(--brand) 70%, transparent), + 0 16px 30px color-mix(in oklab, var(--brand) 28%, transparent), + inset 0 0 0 1px color-mix(in oklab, var(--brand) 84%, transparent); + transform: translateY(0); z-index: 2; } +.ops-bot-card.is-active::after { + content: ''; + position: absolute; + inset: 0; + border-radius: 12px; + border: 2px solid color-mix(in oklab, var(--brand) 78%, transparent); + pointer-events: none; +} + +.ops-bot-card.state-running { + background: linear-gradient(145deg, color-mix(in oklab, var(--ok) 14%, var(--panel-soft) 86%), color-mix(in oklab, var(--ok) 8%, var(--panel) 92%)); +} + +.ops-bot-card.state-stopped { + background: linear-gradient(145deg, color-mix(in oklab, #b79aa2 14%, var(--panel-soft) 86%), color-mix(in oklab, #b79aa2 7%, var(--panel) 93%)); +} + +.ops-bot-card.state-disabled { + background: linear-gradient(145deg, color-mix(in oklab, #9ca3b5 14%, var(--panel-soft) 86%), color-mix(in oklab, #9ca3b5 7%, var(--panel) 93%)); +} + .ops-bot-top { align-items: flex-start; } @@ -249,26 +275,89 @@ margin-top: 10px; display: flex; align-items: center; - justify-content: flex-end; + justify-content: space-between; gap: 6px; } +.ops-bot-actions-main { + display: inline-flex; + align-items: center; + gap: 6px; +} + +.ops-bot-enable-switch { + position: relative; + display: inline-flex; + align-items: center; + user-select: none; + cursor: pointer; +} + +.ops-bot-enable-switch input { + position: absolute; + opacity: 0; + width: 0; + height: 0; +} + +.ops-bot-enable-switch-track { + position: relative; + width: 36px; + height: 20px; + border-radius: 999px; + border: 1px solid color-mix(in oklab, var(--line) 82%, transparent); + background: color-mix(in oklab, #9ca3b5 42%, var(--panel-soft) 58%); + transition: background 0.2s ease, border-color 0.2s ease; +} + +.ops-bot-enable-switch-track::after { + content: ''; + position: absolute; + left: 2px; + top: 2px; + width: 14px; + height: 14px; + border-radius: 999px; + background: color-mix(in oklab, var(--text) 75%, #fff 25%); + transition: transform 0.2s ease, background 0.2s ease; +} + +.ops-bot-enable-switch input:checked + .ops-bot-enable-switch-track { + border-color: color-mix(in oklab, var(--ok) 66%, var(--line) 34%); + background: color-mix(in oklab, var(--ok) 46%, var(--panel-soft) 54%); +} + +.ops-bot-enable-switch input:checked + .ops-bot-enable-switch-track::after { + transform: translateX(16px); + background: #fff; +} + +.ops-bot-enable-switch input:disabled + .ops-bot-enable-switch-track { + opacity: 0.58; + cursor: not-allowed; +} + .ops-bot-strip { position: absolute; left: 0; top: 8px; bottom: 8px; - width: 4px; + width: 3px; border-radius: 999px; - background: #d14b4b; + background: color-mix(in oklab, var(--line) 80%, transparent); + opacity: 0.7; } .ops-bot-strip.is-running { - background: #2ca476; + background: linear-gradient(180deg, color-mix(in oklab, var(--ok) 80%, #9be8c6 20%), color-mix(in oklab, var(--ok) 54%, transparent)); } .ops-bot-strip.is-stopped { - background: #d14b4b; + background: linear-gradient(180deg, color-mix(in oklab, var(--err) 74%, #e7b1ba 26%), color-mix(in oklab, var(--err) 54%, transparent)); +} + +.ops-bot-strip.is-disabled { + background: linear-gradient(180deg, color-mix(in oklab, #9ca3b5 82%, #d4d9e2 18%), color-mix(in oklab, #9ca3b5 52%, transparent)); } .ops-bot-icon-btn { @@ -3303,6 +3392,26 @@ background: #f7fbff; } +.app-shell[data-theme='light'] .ops-bot-card.state-running { + background: linear-gradient(145deg, #edfdf6, #f7fffb); +} + +.app-shell[data-theme='light'] .ops-bot-card.state-stopped { + background: linear-gradient(145deg, #fdf0f2, #fff7f8); +} + +.app-shell[data-theme='light'] .ops-bot-card.state-disabled { + background: linear-gradient(145deg, #eff2f6, #f8fafc); +} + +.app-shell[data-theme='light'] .ops-bot-card.is-active { + border-color: #3f74df; + box-shadow: + 0 0 0 2px rgba(63, 116, 223, 0.45), + 0 16px 32px rgba(63, 116, 223, 0.26), + inset 0 0 0 1px rgba(63, 116, 223, 0.78); +} + .app-shell[data-theme='light'] .ops-preview, .app-shell[data-theme='light'] .ops-status-pill { background: #ffffff; diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index 604b7e5..d08922d 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -956,7 +956,7 @@ export function BotDashboardModule({ const [operatingBotId, setOperatingBotId] = useState(null); const [sendingByBot, setSendingByBot] = useState>({}); const [interruptingByBot, setInterruptingByBot] = useState>({}); - const [controlStateByBot, setControlStateByBot] = useState>({}); + const [controlStateByBot, setControlStateByBot] = useState>({}); const chatBottomRef = useRef(null); const chatScrollRef = useRef(null); const chatAutoFollowRef = useRef(true); @@ -1080,6 +1080,14 @@ export function BotDashboardModule({ const [templateAgentText, setTemplateAgentText] = useState(''); const [templateTopicText, setTemplateTopicText] = useState(''); const [workspaceHoverCard, setWorkspaceHoverCard] = useState(null); + const botSearchInputName = useMemo( + () => `nbot-search-${Math.random().toString(36).slice(2, 10)}`, + [], + ); + const workspaceSearchInputName = useMemo( + () => `nbot-workspace-search-${Math.random().toString(36).slice(2, 10)}`, + [], + ); const voiceRecorderRef = useRef(null); const voiceStreamRef = useRef(null); const voiceChunksRef = useRef([]); @@ -1440,6 +1448,7 @@ export function BotDashboardModule({ [activeBots], ); const hasForcedBot = Boolean(String(forcedBotId || '').trim()); + const singleBotHomeMode = hasForcedBot; const compactListFirstMode = compactMode && !hasForcedBot; const isCompactListPage = compactListFirstMode && !selectedBotId; const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId); @@ -1545,8 +1554,9 @@ export function BotDashboardModule({ } }, [templateTopicText, effectiveTopicPresetTemplates.length]); const selectedBotControlState = selectedBot ? controlStateByBot[selectedBot.id] : undefined; + const selectedBotEnabled = Boolean(selectedBot && selectedBot.enabled !== false); const isSending = selectedBot ? Boolean(sendingByBot[selectedBot.id]) : false; - const canChat = Boolean(selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState); + const canChat = Boolean(selectedBotEnabled && selectedBot && selectedBot.docker_status === 'RUNNING' && !selectedBotControlState); const isChatEnabled = Boolean(canChat && !isSending); const conversation = useMemo(() => mergeConversation(messages), [messages]); @@ -3502,7 +3512,7 @@ export function BotDashboardModule({ const batchStartBots = async () => { if (isBatchOperating) return; - const candidates = bots.filter((bot) => String(bot.docker_status || '').toUpperCase() !== 'RUNNING'); + const candidates = bots.filter((bot) => bot.enabled !== false && String(bot.docker_status || '').toUpperCase() !== 'RUNNING'); if (candidates.length === 0) { notify(t.batchStartNone, { tone: 'warning' }); return; @@ -3536,7 +3546,7 @@ export function BotDashboardModule({ const batchStopBots = async () => { if (isBatchOperating) return; - const candidates = bots.filter((bot) => String(bot.docker_status || '').toUpperCase() === 'RUNNING'); + const candidates = bots.filter((bot) => bot.enabled !== false && String(bot.docker_status || '').toUpperCase() === 'RUNNING'); if (candidates.length === 0) { notify(t.batchStopNone, { tone: 'warning' }); return; @@ -3639,6 +3649,35 @@ export function BotDashboardModule({ } }; + const setBotEnabled = async (id: string, enabled: boolean) => { + setOperatingBotId(id); + setControlStateByBot((prev) => ({ ...prev, [id]: enabled ? 'enabling' : 'disabling' })); + try { + if (enabled) { + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/enable`); + } else { + const ok = await confirm({ + title: t.disable, + message: t.disableConfirm(id), + tone: 'warning', + }); + if (!ok) return; + await axios.post(`${APP_ENDPOINTS.apiBase}/bots/${id}/disable`); + } + await refresh(); + notify(enabled ? t.enableDone : t.disableDone, { tone: 'success' }); + } catch (error: any) { + notify(error?.response?.data?.detail || (enabled ? t.enableFail : t.disableFail), { tone: 'error' }); + } finally { + setOperatingBotId(null); + setControlStateByBot((prev) => { + const next = { ...prev }; + delete next[id]; + return next; + }); + } + }; + const send = async () => { if (!selectedBot || !canChat || isSending) return; if (!command.trim() && pendingAttachments.length === 0 && !quotedReply) return; @@ -4686,15 +4725,22 @@ export function BotDashboardModule({
setBotListQuery(e.target.value)} placeholder={t.botSearchPlaceholder} aria-label={t.botSearchPlaceholder} - autoComplete="off" + autoComplete="new-password" autoCorrect="off" autoCapitalize="none" spellCheck={false} - name="bot-search" + inputMode="search" + name={botSearchInputName} + id={botSearchInputName} + data-form-type="other" + data-lpignore="true" + data-1p-ignore="true" + data-bwignore="true" />
) : null} @@ -5163,18 +5215,21 @@ export function BotDashboardModule({

{t.runtime}

- void restartBot(selectedBot.id, selectedBot.docker_status)} - disabled={operatingBotId === selectedBot.id} - tooltip={t.restart} - aria-label={t.restart} - > - - + {!singleBotHomeMode ? ( + void restartBot(selectedBot.id, selectedBot.docker_status)} + disabled={operatingBotId === selectedBot.id || !selectedBotEnabled} + tooltip={t.restart} + aria-label={t.restart} + > + + + ) : null} setRuntimeMenuOpen((v) => !v)} + disabled={!selectedBotEnabled} tooltip={runtimeMoreLabel} aria-label={runtimeMoreLabel} aria-haspopup="menu" @@ -5184,126 +5239,130 @@ export function BotDashboardModule({ {runtimeMenuOpen ? (
- - - - - - - - - + {!singleBotHomeMode ? ( + <> + + + + + + + + + + + ) : null}