diff --git a/backend/core/config_manager.py b/backend/core/config_manager.py index 8c3367a..35819ba 100644 --- a/backend/core/config_manager.py +++ b/backend/core/config_manager.py @@ -95,7 +95,7 @@ class BotConfigManager: "token": secret, "proxy": extra.get("proxy", ""), "replyToMessage": bool(extra.get("replyToMessage", False)), - "allowFrom": extra.get("allowFrom", []), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), } continue @@ -106,7 +106,7 @@ class BotConfigManager: "appSecret": secret, "encryptKey": extra.get("encryptKey", ""), "verificationToken": extra.get("verificationToken", ""), - "allowFrom": extra.get("allowFrom", []), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), } continue @@ -115,7 +115,7 @@ class BotConfigManager: "enabled": enabled, "clientId": external, "clientSecret": secret, - "allowFrom": extra.get("allowFrom", []), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), } continue @@ -137,7 +137,7 @@ class BotConfigManager: "enabled": enabled, "appId": external, "secret": secret, - "allowFrom": extra.get("allowFrom", []), + "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])), } continue @@ -167,3 +167,15 @@ class BotConfigManager: f.write(str(content).strip() + "\n") return dot_nanobot_dir + + @staticmethod + def _normalize_allow_from(raw: Any) -> List[str]: + rows: List[str] = [] + if isinstance(raw, list): + for item in raw: + text = str(item or "").strip() + if text and text not in rows: + rows.append(text) + if not rows: + return ["*"] + return rows diff --git a/backend/main.py b/backend/main.py index 1552b9c..bfaa233 100644 --- a/backend/main.py +++ b/backend/main.py @@ -615,6 +615,18 @@ def _normalize_channel_extra(raw: Any) -> Dict[str, Any]: return raw +def _normalize_allow_from(raw: Any) -> List[str]: + rows: List[str] = [] + if isinstance(raw, list): + for item in raw: + text = str(item or "").strip() + if text and text not in rows: + rows.append(text) + if not rows: + return ["*"] + return rows + + def _read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]: if not isinstance(channels_cfg, dict): return False, False @@ -643,18 +655,18 @@ def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Di extra = { "encryptKey": cfg.get("encryptKey", ""), "verificationToken": cfg.get("verificationToken", ""), - "allowFrom": cfg.get("allowFrom", []), + "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), } elif ctype == "dingtalk": external_app_id = str(cfg.get("clientId") or "") app_secret = str(cfg.get("clientSecret") or "") - extra = {"allowFrom": cfg.get("allowFrom", [])} + extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))} elif ctype == "telegram": app_secret = str(cfg.get("token") or "") extra = { "proxy": cfg.get("proxy", ""), "replyToMessage": bool(cfg.get("replyToMessage", False)), - "allowFrom": cfg.get("allowFrom", []), + "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])), } elif ctype == "slack": external_app_id = str(cfg.get("botToken") or "") @@ -669,7 +681,7 @@ def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Di elif ctype == "qq": external_app_id = str(cfg.get("appId") or "") app_secret = str(cfg.get("secret") or "") - extra = {"allowFrom": cfg.get("allowFrom", [])} + extra = {"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 "" @@ -707,14 +719,14 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]: "appSecret": app_secret, "encryptKey": extra.get("encryptKey", ""), "verificationToken": extra.get("verificationToken", ""), - "allowFrom": extra.get("allowFrom", []), + "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), } if ctype == "dingtalk": return { "enabled": enabled, "clientId": external_app_id, "clientSecret": app_secret, - "allowFrom": extra.get("allowFrom", []), + "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), } if ctype == "telegram": return { @@ -722,7 +734,7 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]: "token": app_secret, "proxy": extra.get("proxy", ""), "replyToMessage": bool(extra.get("replyToMessage", False)), - "allowFrom": extra.get("allowFrom", []), + "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), } if ctype == "slack": return { @@ -740,7 +752,7 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]: "enabled": enabled, "appId": external_app_id, "secret": app_secret, - "allowFrom": extra.get("allowFrom", []), + "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])), } merged = dict(extra) merged.update( diff --git a/frontend/src/App.css b/frontend/src/App.css index 9d0385e..53a8974 100644 --- a/frontend/src/App.css +++ b/frontend/src/App.css @@ -79,12 +79,43 @@ body { align-items: flex-start; } +.app-header-actions { + display: inline-flex; + align-items: center; + justify-content: flex-end; + gap: 8px; +} + +.app-header-collapsible { + transition: padding 0.2s ease; +} + +.app-header-collapsible.is-collapsed { + padding-top: 8px; + padding-bottom: 8px; + cursor: pointer; +} + +.app-header-collapsible.is-collapsed .app-header-top { + align-items: center; +} + +.app-header-toggle { + flex: 0 0 auto; +} + .app-title { display: flex; align-items: center; gap: 10px; } +.app-title-main { + display: inline-flex; + align-items: center; + gap: 8px; +} + .app-title-icon { width: 22px; height: 22px; @@ -109,6 +140,23 @@ body { font-size: 12px; } +.app-header-toggle-inline { + border: 0; + background: transparent; + color: var(--icon); + display: inline-flex; + align-items: center; + justify-content: center; + width: 20px; + height: 20px; + padding: 0; + cursor: pointer; +} + +.app-header-toggle-inline:hover { + color: var(--brand); +} + .global-switches { display: flex; gap: 8px; diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 81566be..f3f556d 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -1,6 +1,6 @@ import { useEffect, useMemo, useState, type ReactElement } from 'react'; import axios from 'axios'; -import { MoonStar, SunMedium, X } from 'lucide-react'; +import { ChevronDown, ChevronUp, MoonStar, SunMedium, X } from 'lucide-react'; import { useAppStore } from './store/appStore'; import { useBotsSync } from './hooks/useBotsSync'; import { APP_ENDPOINTS } from './config/env'; @@ -27,6 +27,8 @@ function AuthenticatedApp({ const [showCreateWizard, setShowCreateWizard] = useState(false); useBotsSync(); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); + const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim()); + const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView); useEffect(() => { const forced = String(forcedBotId || '').trim(); @@ -39,19 +41,44 @@ function AuthenticatedApp({ document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; }, [activeBots, t.title, forcedBotId]); + useEffect(() => { + setHeaderCollapsed(isSingleBotCompactView); + }, [isSingleBotCompactView, forcedBotId]); + return (
-
+
{ + if (isSingleBotCompactView && headerCollapsed) setHeaderCollapsed(false); + }} + >
Nanobot -
+

{t.title}

+ {isSingleBotCompactView ? ( + + ) : null}
-
+
+ {!headerCollapsed ? ( +
+
+ ) : null}
diff --git a/frontend/src/i18n/app.en.ts b/frontend/src/i18n/app.en.ts index a8361ec..f8a1686 100644 --- a/frontend/src/i18n/app.en.ts +++ b/frontend/src/i18n/app.en.ts @@ -7,6 +7,8 @@ export const appEn = { zh: 'Chinese', en: 'English', close: 'Close', + expandHeader: 'Expand header', + collapseHeader: 'Collapse header', nav: { images: { title: 'Image Factory', subtitle: 'Manage registered images' }, onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' }, diff --git a/frontend/src/i18n/app.zh-cn.ts b/frontend/src/i18n/app.zh-cn.ts index c3cc93e..0f98c3c 100644 --- a/frontend/src/i18n/app.zh-cn.ts +++ b/frontend/src/i18n/app.zh-cn.ts @@ -7,6 +7,8 @@ export const appZhCn = { zh: '中文', en: 'English', close: '关闭', + expandHeader: '展开头部', + collapseHeader: '收起头部', nav: { images: { title: '镜像工厂', subtitle: '管理已登记镜像' }, onboarding: { title: '创建向导', subtitle: '分步创建 Bot' }, diff --git a/frontend/src/i18n/dashboard.en.ts b/frontend/src/i18n/dashboard.en.ts index 1b5ba07..0aecd26 100644 --- a/frontend/src/i18n/dashboard.en.ts +++ b/frontend/src/i18n/dashboard.en.ts @@ -64,6 +64,7 @@ export const dashboardEn = { stop: 'Stop', start: 'Start', restart: 'Restart Bot', + restartConfirm: (id: string) => `Restart bot ${id}?`, restartFail: 'Restart failed. Check backend logs.', delete: 'Delete', noConversation: 'No conversation yet. Send a command and bot replies will appear here.', diff --git a/frontend/src/i18n/dashboard.zh-cn.ts b/frontend/src/i18n/dashboard.zh-cn.ts index e971254..db18f39 100644 --- a/frontend/src/i18n/dashboard.zh-cn.ts +++ b/frontend/src/i18n/dashboard.zh-cn.ts @@ -64,6 +64,7 @@ export const dashboardZhCn = { stop: '停止', start: '启动', restart: '重启 Bot', + restartConfirm: (id: string) => `确认重启 Bot ${id}?`, restartFail: '重启失败,请查看后端日志。', delete: '删除', noConversation: '暂无对话消息。请先发送指令,Bot 回复会在这里按标准会话格式展示。', diff --git a/frontend/src/modules/dashboard/BotDashboardModule.css b/frontend/src/modules/dashboard/BotDashboardModule.css index 7c42ebc..38bfb65 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.css +++ b/frontend/src/modules/dashboard/BotDashboardModule.css @@ -21,8 +21,8 @@ position: fixed; right: 14px; bottom: 14px; - width: 42px; - height: 42px; + width: 48px; + height: 48px; border-radius: 999px; border: 1px solid color-mix(in oklab, var(--brand) 58%, var(--line) 42%); background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%); @@ -30,9 +30,83 @@ display: inline-flex; align-items: center; justify-content: center; - box-shadow: 0 10px 24px rgba(9, 15, 28, 0.35); + box-shadow: + 0 10px 24px rgba(9, 15, 28, 0.42), + 0 0 0 2px color-mix(in oklab, var(--brand) 22%, transparent); z-index: 85; cursor: pointer; + overflow: visible; + transform: translateY(0); + animation: ops-fab-float 2.2s ease-in-out infinite; +} + +.ops-compact-fab-switch::before { + content: ''; + position: absolute; + inset: -7px; + border-radius: 999px; + border: 2px solid color-mix(in oklab, var(--brand) 60%, transparent); + opacity: 0.45; + animation: ops-fab-pulse 1.8s ease-out infinite; + pointer-events: none; +} + +.ops-compact-fab-switch::after { + content: ''; + position: absolute; + inset: -1px; + border-radius: 999px; + background: + radial-gradient(circle at 50% 50%, color-mix(in oklab, var(--brand) 20%, transparent) 0%, transparent 72%); + opacity: 0.9; + pointer-events: none; +} + +.ops-compact-fab-switch.is-chat { + box-shadow: + 0 10px 24px rgba(9, 15, 28, 0.42), + 0 0 18px color-mix(in oklab, #5c98ff 60%, transparent), + 0 0 0 2px color-mix(in oklab, #5c98ff 35%, transparent); +} + +.ops-compact-fab-switch.is-runtime { + box-shadow: + 0 10px 24px rgba(9, 15, 28, 0.42), + 0 0 18px color-mix(in oklab, #40d6c3 62%, transparent), + 0 0 0 2px color-mix(in oklab, #40d6c3 38%, transparent); +} + +.ops-compact-fab-switch.is-runtime::before { + border-color: color-mix(in oklab, #40d6c3 62%, transparent); +} + +.ops-compact-fab-switch:hover { + transform: translateY(-1px) scale(1.03); +} + +@keyframes ops-fab-pulse { + 0% { + transform: scale(0.92); + opacity: 0.62; + } + 70% { + transform: scale(1.15); + opacity: 0; + } + 100% { + transform: scale(1.15); + opacity: 0; + } +} + +@keyframes ops-fab-float { + 0%, + 100% { + transform: translateY(0); + } + 50% { + transform: translateY(-2px); + } } .ops-list-actions { @@ -549,22 +623,75 @@ border-radius: 14px; border: 1px solid var(--line); padding: 10px 12px; + position: relative; + overflow: visible; } .ops-chat-bubble.assistant { + --bubble-bg: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%); + --bubble-border: #3661aa; border-color: #3661aa; - background: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%); + background: var(--bubble-bg); + border-bottom-left-radius: 4px; } .ops-chat-bubble.assistant.progress { + --bubble-bg: color-mix(in oklab, var(--brand-soft) 35%, var(--panel-soft) 65%); + --bubble-border: color-mix(in oklab, var(--brand) 55%, var(--line) 45%); border-style: dashed; border-color: color-mix(in oklab, var(--brand) 55%, var(--line) 45%); - background: color-mix(in oklab, var(--brand-soft) 35%, var(--panel-soft) 65%); + background: var(--bubble-bg); + border-bottom-left-radius: 4px; } .ops-chat-bubble.user { + --bubble-bg: color-mix(in oklab, #d9fff0 36%, var(--panel-soft) 64%); + --bubble-border: #2f8f7f; border-color: #2f8f7f; - background: color-mix(in oklab, #d9fff0 36%, var(--panel-soft) 64%); + background: var(--bubble-bg); + border-top-right-radius: 4px; +} + +.ops-chat-bubble.assistant::before, +.ops-chat-bubble.assistant::after, +.ops-chat-bubble.user::before, +.ops-chat-bubble.user::after { + content: ''; + position: absolute; + width: 0; + height: 0; +} + +.ops-chat-bubble.assistant::before { + left: -9px; + bottom: 8px; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-right: 9px solid var(--bubble-border); +} + +.ops-chat-bubble.assistant::after { + left: -7px; + bottom: 9px; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-right: 8px solid var(--bubble-bg); +} + +.ops-chat-bubble.user::before { + right: -9px; + top: 8px; + border-top: 8px solid transparent; + border-bottom: 8px solid transparent; + border-left: 9px solid var(--bubble-border); +} + +.ops-chat-bubble.user::after { + right: -7px; + top: 9px; + border-top: 7px solid transparent; + border-bottom: 7px solid transparent; + border-left: 8px solid var(--bubble-bg); } .ops-chat-meta { @@ -748,7 +875,8 @@ .ops-avatar.bot { background: #102d63; - border-color: #4e70ad; + border: none; + box-shadow: none; } .ops-avatar.bot img { @@ -763,6 +891,10 @@ color: #e9f2ff; } +.ops-chat-row.is-user .ops-avatar.user { + margin-left: 10px; +} + .ops-chat-empty { border: 1px dashed var(--line); border-radius: 12px; @@ -2081,13 +2213,19 @@ } .app-shell[data-theme='light'] .ops-chat-bubble.assistant { - background: #eaf1ff; - border-color: #a9c1ee; + --bubble-bg: #eaf1ff; + --bubble-border: #a9c1ee; + background: var(--bubble-bg); + border-color: var(--bubble-border); + border-bottom-left-radius: 4px; } .app-shell[data-theme='light'] .ops-chat-bubble.user { - background: #e8f6f2; - border-color: #9ccfc2; + --bubble-bg: #e8f6f2; + --bubble-border: #9ccfc2; + background: var(--bubble-bg); + border-color: var(--bubble-border); + border-top-right-radius: 4px; } .app-shell[data-theme='light'] .ops-avatar.bot { diff --git a/frontend/src/modules/dashboard/BotDashboardModule.tsx b/frontend/src/modules/dashboard/BotDashboardModule.tsx index afc8dc3..cb252b3 100644 --- a/frontend/src/modules/dashboard/BotDashboardModule.tsx +++ b/frontend/src/modules/dashboard/BotDashboardModule.tsx @@ -2071,6 +2071,12 @@ export function BotDashboardModule({ const restartBot = async (id: string, status: string) => { const normalized = String(status || '').toUpperCase(); + const ok = await confirm({ + title: t.restart, + message: t.restartConfirm(id), + tone: 'warning', + }); + if (!ok) return; setOperatingBotId(id); try { if (normalized === 'RUNNING') { @@ -3366,7 +3372,7 @@ export function BotDashboardModule({
{compactMode && isCompactMobile ? ( setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))} tooltip={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')} aria-label={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}