v0.1.4-p2

main
mula.liu 2026-03-17 00:07:25 +08:00
parent 04b807da8e
commit 3a151d7e8f
9 changed files with 501 additions and 204 deletions

View File

@ -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()

View File

@ -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)

View File

@ -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)

View File

@ -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',

View File

@ -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: '运行状态',

View File

@ -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;

View File

@ -956,7 +956,7 @@ export function BotDashboardModule({
const [operatingBotId, setOperatingBotId] = useState<string | null>(null);
const [sendingByBot, setSendingByBot] = useState<Record<string, boolean>>({});
const [interruptingByBot, setInterruptingByBot] = useState<Record<string, boolean>>({});
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping'>>({});
const [controlStateByBot, setControlStateByBot] = useState<Record<string, 'starting' | 'stopping' | 'enabling' | 'disabling'>>({});
const chatBottomRef = useRef<HTMLDivElement | null>(null);
const chatScrollRef = useRef<HTMLDivElement | null>(null);
const chatAutoFollowRef = useRef(true);
@ -1080,6 +1080,14 @@ export function BotDashboardModule({
const [templateAgentText, setTemplateAgentText] = useState('');
const [templateTopicText, setTemplateTopicText] = useState('');
const [workspaceHoverCard, setWorkspaceHoverCard] = useState<WorkspaceHoverCardState | null>(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<MediaRecorder | null>(null);
const voiceStreamRef = useRef<MediaStream | null>(null);
const voiceChunksRef = useRef<BlobPart[]>([]);
@ -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({
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={botListQuery}
onChange={(e) => 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"
/>
<button
type="button"
@ -4720,18 +4766,22 @@ export function BotDashboardModule({
const selected = selectedBotId === bot.id;
const controlState = controlStateByBot[bot.id];
const isOperating = operatingBotId === bot.id;
const isEnabled = bot.enabled !== false;
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
const isEnabling = controlState === 'enabling';
const isDisabling = controlState === 'disabling';
const isRunning = String(bot.docker_status || '').toUpperCase() === 'RUNNING';
return (
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''}`}
className={`ops-bot-card ${selected ? 'is-active' : ''} ${isEnabled ? (isRunning ? 'state-running' : 'state-stopped') : 'state-disabled'}`}
onClick={() => {
setSelectedBotId(bot.id);
if (compactMode) setCompactPanelTab('chat');
}}
>
<span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" />
<span className={`ops-bot-strip ${isEnabled ? (isRunning ? 'is-running' : 'is-stopped') : 'is-disabled'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
<div className="ops-bot-name-row">
@ -4757,13 +4807,54 @@ export function BotDashboardModule({
<div className="mono ops-bot-id">{bot.id}</div>
</div>
<div className="ops-bot-top-actions">
{!isEnabled ? (
<span className="badge badge-err">{t.disabled}</span>
) : null}
<span className={bot.docker_status === 'RUNNING' ? 'badge badge-ok' : 'badge badge-unknown'}>{bot.docker_status}</span>
</div>
</div>
<div className="ops-bot-meta">{t.image}: <span className="mono">{bot.image_tag || '-'}</span></div>
<div className="ops-bot-actions">
<label
className="ops-bot-enable-switch"
title={isEnabled ? t.disable : t.enable}
onClick={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={isEnabled}
disabled={isOperating || isEnabling || isDisabling}
onChange={(e) => {
void setBotEnabled(bot.id, e.target.checked);
}}
aria-label={isZh ? '启用/停用' : 'Enable/Disable'}
/>
<span className="ops-bot-enable-switch-track" />
</label>
<div className="ops-bot-actions-main">
<LucentIconButton
className={`btn btn-sm ops-bot-icon-btn ${isRunning ? 'ops-bot-action-stop' : 'ops-bot-action-start'}`}
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void (isRunning ? stopBot(bot.id, bot.docker_status) : startBot(bot.id, bot.docker_status));
}}
tooltip={isRunning ? t.stop : t.start}
aria-label={isRunning ? t.stop : t.start}
>
{isStarting || isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : isRunning ? <Square size={14} /> : <Power size={14} />}
</LucentIconButton>
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-monitor"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
openResourceMonitor(bot.id);
@ -4773,51 +4864,9 @@ export function BotDashboardModule({
>
<Gauge size={14} />
</LucentIconButton>
{bot.docker_status === 'RUNNING' ? (
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-stop"
disabled={isOperating}
onClick={(e) => {
e.stopPropagation();
void stopBot(bot.id, bot.docker_status);
}}
tooltip={t.stop}
aria-label={t.stop}
>
{isStopping ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : <Square size={14} />}
</LucentIconButton>
) : (
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-start"
disabled={isOperating}
onClick={(e) => {
e.stopPropagation();
void startBot(bot.id, bot.docker_status);
}}
tooltip={t.start}
aria-label={t.start}
>
{isStarting ? (
<span className="ops-control-pending">
<span className="ops-control-dots" aria-hidden="true">
<i />
<i />
<i />
</span>
</span>
) : <Power size={14} />}
</LucentIconButton>
)}
<LucentIconButton
className="btn btn-sm ops-bot-icon-btn ops-bot-action-delete"
disabled={isOperating || !isEnabled}
onClick={(e) => {
e.stopPropagation();
void removeBot(bot.id);
@ -4829,6 +4878,7 @@ export function BotDashboardModule({
</LucentIconButton>
</div>
</div>
</div>
);
})}
{filteredBots.length === 0 ? (
@ -5138,6 +5188,8 @@ export function BotDashboardModule({
? t.botStarting
: selectedBotControlState === 'stopping'
? t.botStopping
: !selectedBotEnabled
? t.botDisabledHint
: t.chatDisabled}
</div>
</div>
@ -5163,18 +5215,21 @@ export function BotDashboardModule({
<div className="row-between ops-runtime-head">
<h2 style={{ fontSize: 18 }}>{t.runtime}</h2>
<div className="ops-panel-tools" ref={runtimeMenuRef}>
{!singleBotHomeMode ? (
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => void restartBot(selectedBot.id, selectedBot.docker_status)}
disabled={operatingBotId === selectedBot.id}
disabled={operatingBotId === selectedBot.id || !selectedBotEnabled}
tooltip={t.restart}
aria-label={t.restart}
>
<RotateCcw size={14} className={operatingBotId === selectedBot.id ? 'animate-spin' : ''} />
</LucentIconButton>
) : null}
<LucentIconButton
className="btn btn-secondary btn-sm icon-btn"
onClick={() => setRuntimeMenuOpen((v) => !v)}
disabled={!selectedBotEnabled}
tooltip={runtimeMoreLabel}
aria-label={runtimeMoreLabel}
aria-haspopup="menu"
@ -5184,6 +5239,8 @@ export function BotDashboardModule({
</LucentIconButton>
{runtimeMenuOpen ? (
<div className="ops-more-menu" role="menu" aria-label={runtimeMoreLabel}>
{!singleBotHomeMode ? (
<>
<button
className="ops-more-item"
role="menuitem"
@ -5304,6 +5361,8 @@ export function BotDashboardModule({
<FileText size={14} />
<span>{t.agent}</span>
</button>
</>
) : null}
<button
className="ops-more-item"
role="menuitem"
@ -5383,10 +5442,22 @@ export function BotDashboardModule({
<div className="ops-searchbar">
<input
className="input ops-search-input ops-search-input-with-icon"
type="search"
value={workspaceQuery}
onChange={(e) => setWorkspaceQuery(e.target.value)}
placeholder={t.workspaceSearchPlaceholder}
aria-label={t.workspaceSearchPlaceholder}
autoComplete="new-password"
autoCorrect="off"
autoCapitalize="none"
spellCheck={false}
inputMode="search"
name={workspaceSearchInputName}
id={workspaceSearchInputName}
data-form-type="other"
data-lpignore="true"
data-1p-ignore="true"
data-bwignore="true"
/>
<button
type="button"

View File

@ -65,6 +65,7 @@ export const useAppStore = create<AppStore>((set) => ({
nextBots[bot.id] = {
...prev,
...bot,
enabled: preferDefined(bot.enabled, prev?.enabled),
image_tag: preferDefined(bot.image_tag, prev?.image_tag),
llm_provider: preferDefined(bot.llm_provider, prev?.llm_provider),
llm_model: preferDefined(bot.llm_model, prev?.llm_model),
@ -103,6 +104,7 @@ export const useAppStore = create<AppStore>((set) => ({
[bot.id]: {
...prev,
...bot,
enabled: preferDefined(bot.enabled, prev?.enabled),
logs: prev?.logs ?? bot.logs ?? [],
messages: prev?.messages ?? bot.messages ?? [],
events: prev?.events ?? bot.events ?? [],

View File

@ -19,6 +19,7 @@ export interface BotEvent {
export interface BotState {
id: string;
name: string;
enabled?: boolean;
access_password?: string;
has_access_password?: boolean;
avatar_model: string;