v0.1.4-p2
parent
04b807da8e
commit
3a151d7e8f
|
|
@ -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()
|
||||
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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: '运行状态',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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 ?? [],
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in New Issue