main
mula.liu 2026-03-10 13:47:28 +08:00
parent 84010e33ac
commit d99ab859ca
10 changed files with 279 additions and 28 deletions

View File

@ -95,7 +95,7 @@ class BotConfigManager:
"token": secret, "token": secret,
"proxy": extra.get("proxy", ""), "proxy": extra.get("proxy", ""),
"replyToMessage": bool(extra.get("replyToMessage", False)), "replyToMessage": bool(extra.get("replyToMessage", False)),
"allowFrom": extra.get("allowFrom", []), "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])),
} }
continue continue
@ -106,7 +106,7 @@ class BotConfigManager:
"appSecret": secret, "appSecret": secret,
"encryptKey": extra.get("encryptKey", ""), "encryptKey": extra.get("encryptKey", ""),
"verificationToken": extra.get("verificationToken", ""), "verificationToken": extra.get("verificationToken", ""),
"allowFrom": extra.get("allowFrom", []), "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])),
} }
continue continue
@ -115,7 +115,7 @@ class BotConfigManager:
"enabled": enabled, "enabled": enabled,
"clientId": external, "clientId": external,
"clientSecret": secret, "clientSecret": secret,
"allowFrom": extra.get("allowFrom", []), "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])),
} }
continue continue
@ -137,7 +137,7 @@ class BotConfigManager:
"enabled": enabled, "enabled": enabled,
"appId": external, "appId": external,
"secret": secret, "secret": secret,
"allowFrom": extra.get("allowFrom", []), "allowFrom": self._normalize_allow_from(extra.get("allowFrom", [])),
} }
continue continue
@ -167,3 +167,15 @@ class BotConfigManager:
f.write(str(content).strip() + "\n") f.write(str(content).strip() + "\n")
return dot_nanobot_dir 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

View File

@ -615,6 +615,18 @@ def _normalize_channel_extra(raw: Any) -> Dict[str, Any]:
return raw 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]: def _read_global_delivery_flags(channels_cfg: Any) -> tuple[bool, bool]:
if not isinstance(channels_cfg, dict): if not isinstance(channels_cfg, dict):
return False, False return False, False
@ -643,18 +655,18 @@ def _channel_cfg_to_api_dict(bot_id: str, ctype: str, cfg: Dict[str, Any]) -> Di
extra = { extra = {
"encryptKey": cfg.get("encryptKey", ""), "encryptKey": cfg.get("encryptKey", ""),
"verificationToken": cfg.get("verificationToken", ""), "verificationToken": cfg.get("verificationToken", ""),
"allowFrom": cfg.get("allowFrom", []), "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
} }
elif ctype == "dingtalk": elif ctype == "dingtalk":
external_app_id = str(cfg.get("clientId") or "") external_app_id = str(cfg.get("clientId") or "")
app_secret = str(cfg.get("clientSecret") or "") app_secret = str(cfg.get("clientSecret") or "")
extra = {"allowFrom": cfg.get("allowFrom", [])} extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
elif ctype == "telegram": elif ctype == "telegram":
app_secret = str(cfg.get("token") or "") app_secret = str(cfg.get("token") or "")
extra = { extra = {
"proxy": cfg.get("proxy", ""), "proxy": cfg.get("proxy", ""),
"replyToMessage": bool(cfg.get("replyToMessage", False)), "replyToMessage": bool(cfg.get("replyToMessage", False)),
"allowFrom": cfg.get("allowFrom", []), "allowFrom": _normalize_allow_from(cfg.get("allowFrom", [])),
} }
elif ctype == "slack": elif ctype == "slack":
external_app_id = str(cfg.get("botToken") or "") 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": elif ctype == "qq":
external_app_id = str(cfg.get("appId") or "") external_app_id = str(cfg.get("appId") or "")
app_secret = str(cfg.get("secret") or "") app_secret = str(cfg.get("secret") or "")
extra = {"allowFrom": cfg.get("allowFrom", [])} extra = {"allowFrom": _normalize_allow_from(cfg.get("allowFrom", []))}
else: else:
external_app_id = str( external_app_id = str(
cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or "" cfg.get("appId") or cfg.get("clientId") or cfg.get("botToken") or cfg.get("externalAppId") or ""
@ -707,14 +719,14 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]:
"appSecret": app_secret, "appSecret": app_secret,
"encryptKey": extra.get("encryptKey", ""), "encryptKey": extra.get("encryptKey", ""),
"verificationToken": extra.get("verificationToken", ""), "verificationToken": extra.get("verificationToken", ""),
"allowFrom": extra.get("allowFrom", []), "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
} }
if ctype == "dingtalk": if ctype == "dingtalk":
return { return {
"enabled": enabled, "enabled": enabled,
"clientId": external_app_id, "clientId": external_app_id,
"clientSecret": app_secret, "clientSecret": app_secret,
"allowFrom": extra.get("allowFrom", []), "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
} }
if ctype == "telegram": if ctype == "telegram":
return { return {
@ -722,7 +734,7 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]:
"token": app_secret, "token": app_secret,
"proxy": extra.get("proxy", ""), "proxy": extra.get("proxy", ""),
"replyToMessage": bool(extra.get("replyToMessage", False)), "replyToMessage": bool(extra.get("replyToMessage", False)),
"allowFrom": extra.get("allowFrom", []), "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
} }
if ctype == "slack": if ctype == "slack":
return { return {
@ -740,7 +752,7 @@ def _channel_api_to_cfg(row: Dict[str, Any]) -> Dict[str, Any]:
"enabled": enabled, "enabled": enabled,
"appId": external_app_id, "appId": external_app_id,
"secret": app_secret, "secret": app_secret,
"allowFrom": extra.get("allowFrom", []), "allowFrom": _normalize_allow_from(extra.get("allowFrom", [])),
} }
merged = dict(extra) merged = dict(extra)
merged.update( merged.update(

View File

@ -79,12 +79,43 @@ body {
align-items: flex-start; 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 { .app-title {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
} }
.app-title-main {
display: inline-flex;
align-items: center;
gap: 8px;
}
.app-title-icon { .app-title-icon {
width: 22px; width: 22px;
height: 22px; height: 22px;
@ -109,6 +140,23 @@ body {
font-size: 12px; 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 { .global-switches {
display: flex; display: flex;
gap: 8px; gap: 8px;

View File

@ -1,6 +1,6 @@
import { useEffect, useMemo, useState, type ReactElement } from 'react'; import { useEffect, useMemo, useState, type ReactElement } from 'react';
import axios from 'axios'; 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 { useAppStore } from './store/appStore';
import { useBotsSync } from './hooks/useBotsSync'; import { useBotsSync } from './hooks/useBotsSync';
import { APP_ENDPOINTS } from './config/env'; import { APP_ENDPOINTS } from './config/env';
@ -27,6 +27,8 @@ function AuthenticatedApp({
const [showCreateWizard, setShowCreateWizard] = useState(false); const [showCreateWizard, setShowCreateWizard] = useState(false);
useBotsSync(); useBotsSync();
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn }); const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView);
useEffect(() => { useEffect(() => {
const forced = String(forcedBotId || '').trim(); const forced = String(forcedBotId || '').trim();
@ -39,19 +41,44 @@ function AuthenticatedApp({
document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`; document.title = botName ? `${t.title} - ${botName}` : `${t.title} - ${forced}`;
}, [activeBots, t.title, forcedBotId]); }, [activeBots, t.title, forcedBotId]);
useEffect(() => {
setHeaderCollapsed(isSingleBotCompactView);
}, [isSingleBotCompactView, forcedBotId]);
return ( return (
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}> <div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className="app-frame"> <div className="app-frame">
<header className="app-header"> <header
className={`app-header ${isSingleBotCompactView ? 'app-header-collapsible' : ''} ${isSingleBotCompactView && headerCollapsed ? 'is-collapsed' : ''}`}
onClick={() => {
if (isSingleBotCompactView && headerCollapsed) setHeaderCollapsed(false);
}}
>
<div className="row-between app-header-top"> <div className="row-between app-header-top">
<div className="app-title"> <div className="app-title">
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" /> <img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div> <div className="app-title-main">
<h1>{t.title}</h1> <h1>{t.title}</h1>
{isSingleBotCompactView ? (
<button
type="button"
className="app-header-toggle-inline"
onClick={(event) => {
event.stopPropagation();
setHeaderCollapsed((v) => !v);
}}
title={headerCollapsed ? t.expandHeader : t.collapseHeader}
aria-label={headerCollapsed ? t.expandHeader : t.collapseHeader}
>
{headerCollapsed ? <ChevronDown size={16} /> : <ChevronUp size={16} />}
</button>
) : null}
</div> </div>
</div> </div>
<div className="global-switches"> <div className="app-header-actions">
{!headerCollapsed ? (
<div className="global-switches">
<div className="switch-compact"> <div className="switch-compact">
<LucentTooltip content={t.dark}> <LucentTooltip content={t.dark}>
<button <button
@ -93,6 +120,8 @@ function AuthenticatedApp({
</button> </button>
</LucentTooltip> </LucentTooltip>
</div> </div>
</div>
) : null}
</div> </div>
</div> </div>
</header> </header>

View File

@ -7,6 +7,8 @@ export const appEn = {
zh: 'Chinese', zh: 'Chinese',
en: 'English', en: 'English',
close: 'Close', close: 'Close',
expandHeader: 'Expand header',
collapseHeader: 'Collapse header',
nav: { nav: {
images: { title: 'Image Factory', subtitle: 'Manage registered images' }, images: { title: 'Image Factory', subtitle: 'Manage registered images' },
onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' }, onboarding: { title: 'Creation Wizard', subtitle: 'Create bot step-by-step' },

View File

@ -7,6 +7,8 @@ export const appZhCn = {
zh: '中文', zh: '中文',
en: 'English', en: 'English',
close: '关闭', close: '关闭',
expandHeader: '展开头部',
collapseHeader: '收起头部',
nav: { nav: {
images: { title: '镜像工厂', subtitle: '管理已登记镜像' }, images: { title: '镜像工厂', subtitle: '管理已登记镜像' },
onboarding: { title: '创建向导', subtitle: '分步创建 Bot' }, onboarding: { title: '创建向导', subtitle: '分步创建 Bot' },

View File

@ -64,6 +64,7 @@ export const dashboardEn = {
stop: 'Stop', stop: 'Stop',
start: 'Start', start: 'Start',
restart: 'Restart Bot', restart: 'Restart Bot',
restartConfirm: (id: string) => `Restart bot ${id}?`,
restartFail: 'Restart failed. Check backend logs.', restartFail: 'Restart failed. Check backend logs.',
delete: 'Delete', delete: 'Delete',
noConversation: 'No conversation yet. Send a command and bot replies will appear here.', noConversation: 'No conversation yet. Send a command and bot replies will appear here.',

View File

@ -64,6 +64,7 @@ export const dashboardZhCn = {
stop: '停止', stop: '停止',
start: '启动', start: '启动',
restart: '重启 Bot', restart: '重启 Bot',
restartConfirm: (id: string) => `确认重启 Bot ${id}`,
restartFail: '重启失败,请查看后端日志。', restartFail: '重启失败,请查看后端日志。',
delete: '删除', delete: '删除',
noConversation: '暂无对话消息。请先发送指令Bot 回复会在这里按标准会话格式展示。', noConversation: '暂无对话消息。请先发送指令Bot 回复会在这里按标准会话格式展示。',

View File

@ -21,8 +21,8 @@
position: fixed; position: fixed;
right: 14px; right: 14px;
bottom: 14px; bottom: 14px;
width: 42px; width: 48px;
height: 42px; height: 48px;
border-radius: 999px; border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--brand) 58%, var(--line) 42%); 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%); background: color-mix(in oklab, var(--panel) 70%, var(--brand-soft) 30%);
@ -30,9 +30,83 @@
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
justify-content: 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; z-index: 85;
cursor: pointer; 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 { .ops-list-actions {
@ -549,22 +623,75 @@
border-radius: 14px; border-radius: 14px;
border: 1px solid var(--line); border: 1px solid var(--line);
padding: 10px 12px; padding: 10px 12px;
position: relative;
overflow: visible;
} }
.ops-chat-bubble.assistant { .ops-chat-bubble.assistant {
--bubble-bg: color-mix(in oklab, var(--panel-soft) 82%, var(--panel) 18%);
--bubble-border: #3661aa;
border-color: #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 { .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-style: dashed;
border-color: color-mix(in oklab, var(--brand) 55%, var(--line) 45%); 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 { .ops-chat-bubble.user {
--bubble-bg: color-mix(in oklab, #d9fff0 36%, var(--panel-soft) 64%);
--bubble-border: #2f8f7f;
border-color: #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 { .ops-chat-meta {
@ -748,7 +875,8 @@
.ops-avatar.bot { .ops-avatar.bot {
background: #102d63; background: #102d63;
border-color: #4e70ad; border: none;
box-shadow: none;
} }
.ops-avatar.bot img { .ops-avatar.bot img {
@ -763,6 +891,10 @@
color: #e9f2ff; color: #e9f2ff;
} }
.ops-chat-row.is-user .ops-avatar.user {
margin-left: 10px;
}
.ops-chat-empty { .ops-chat-empty {
border: 1px dashed var(--line); border: 1px dashed var(--line);
border-radius: 12px; border-radius: 12px;
@ -2081,13 +2213,19 @@
} }
.app-shell[data-theme='light'] .ops-chat-bubble.assistant { .app-shell[data-theme='light'] .ops-chat-bubble.assistant {
background: #eaf1ff; --bubble-bg: #eaf1ff;
border-color: #a9c1ee; --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 { .app-shell[data-theme='light'] .ops-chat-bubble.user {
background: #e8f6f2; --bubble-bg: #e8f6f2;
border-color: #9ccfc2; --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 { .app-shell[data-theme='light'] .ops-avatar.bot {

View File

@ -2071,6 +2071,12 @@ export function BotDashboardModule({
const restartBot = async (id: string, status: string) => { const restartBot = async (id: string, status: string) => {
const normalized = String(status || '').toUpperCase(); const normalized = String(status || '').toUpperCase();
const ok = await confirm({
title: t.restart,
message: t.restartConfirm(id),
tone: 'warning',
});
if (!ok) return;
setOperatingBotId(id); setOperatingBotId(id);
try { try {
if (normalized === 'RUNNING') { if (normalized === 'RUNNING') {
@ -3366,7 +3372,7 @@ export function BotDashboardModule({
</div> </div>
{compactMode && isCompactMobile ? ( {compactMode && isCompactMobile ? (
<LucentIconButton <LucentIconButton
className="ops-compact-fab-switch" className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
onClick={() => setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))} onClick={() => setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))}
tooltip={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')} tooltip={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}
aria-label={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')} aria-label={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')}