v0.1.4-p2

main
mula.liu 2026-03-14 13:03:22 +08:00
parent 38bdcdfd63
commit 86370d5824
7 changed files with 161 additions and 34 deletions

View File

@ -1062,6 +1062,14 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
},
}
def _with_body_preview(message: str, preview: Any) -> str:
text = str(message or "").strip()
body = " ".join(str(preview or "").strip().split())
if not body:
return text
body = body[:240]
return f"{text}: {body}" if text else body
if bot_id:
if transport_type == "sse":
probe_headers = dict(headers)
@ -1077,16 +1085,17 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
status_code = probe.get("status_code")
content_type = str(probe.get("content_type") or "")
message = str(probe.get("message") or "").strip()
body_preview = probe.get("body_preview")
if status_code in {401, 403}:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP SSE endpoint", "content_type": content_type, "probe_from": "bot-container"}
if status_code == 404:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint not found", "content_type": content_type, "probe_from": "bot-container"}
if isinstance(status_code, int) and status_code >= 500:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint server error", "content_type": content_type, "probe_from": "bot-container"}
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("MCP SSE endpoint server error", body_preview), "content_type": content_type, "probe_from": "bot-container"}
if not probe.get("ok"):
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": message or "Failed to connect MCP SSE endpoint from bot container", "content_type": content_type, "probe_from": "bot-container"}
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview(message or "Failed to connect MCP SSE endpoint from bot container", body_preview), "content_type": content_type, "probe_from": "bot-container"}
if "text/event-stream" not in content_type.lower():
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Endpoint reachable, but content-type is not text/event-stream", "content_type": content_type, "probe_from": "bot-container"}
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("Endpoint reachable, but content-type is not text/event-stream", body_preview), "content_type": content_type, "probe_from": "bot-container"}
return {"ok": True, "transport": transport_type, "status_code": status_code, "message": "MCP SSE endpoint is reachable", "content_type": content_type, "probe_from": "bot-container"}
probe_headers = dict(headers)
@ -1102,16 +1111,17 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
)
status_code = probe.get("status_code")
message = str(probe.get("message") or "").strip()
body_preview = probe.get("body_preview")
if status_code in {401, 403}:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP endpoint", "probe_from": "bot-container"}
if status_code == 404:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint not found", "probe_from": "bot-container"}
if isinstance(status_code, int) and status_code >= 500:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint server error", "probe_from": "bot-container"}
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview("MCP endpoint server error", body_preview), "probe_from": "bot-container"}
if probe.get("ok") and status_code in {200, 201, 202, 204, 400, 405, 415, 422}:
reachability_msg = "MCP endpoint is reachable" if status_code in {200, 201, 202, 204} else "MCP endpoint is reachable (request format not fully accepted by probe)"
return {"ok": True, "transport": transport_type, "status_code": status_code, "message": reachability_msg, "probe_from": "bot-container"}
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": message or "Unexpected response from MCP endpoint", "probe_from": "bot-container"}
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": _with_body_preview(message or "Unexpected response from MCP endpoint", body_preview), "probe_from": "bot-container"}
try:
with httpx.Client(timeout=httpx.Timeout(timeout_s), follow_redirects=True) as client:
@ -1120,6 +1130,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
req_headers.setdefault("Accept", "text/event-stream")
resp = client.get(url, headers=req_headers)
content_type = str(resp.headers.get("content-type") or "")
body_preview = resp.text[:512]
if resp.status_code in {401, 403}:
return {
"ok": False,
@ -1143,7 +1154,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False,
"transport": transport_type,
"status_code": resp.status_code,
"message": "MCP SSE endpoint server error",
"message": _with_body_preview("MCP SSE endpoint server error", body_preview),
"content_type": content_type,
"probe_from": "backend-host",
}
@ -1152,7 +1163,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False,
"transport": transport_type,
"status_code": resp.status_code,
"message": "Endpoint reachable, but content-type is not text/event-stream",
"message": _with_body_preview("Endpoint reachable, but content-type is not text/event-stream", body_preview),
"content_type": content_type,
"probe_from": "backend-host",
}
@ -1169,6 +1180,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
req_headers.setdefault("Content-Type", "application/json")
req_headers.setdefault("Accept", "application/json, text/event-stream")
resp = client.post(url, headers=req_headers, json=probe_payload)
body_preview = resp.text[:512]
if resp.status_code in {401, 403}:
return {
"ok": False,
@ -1190,7 +1202,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False,
"transport": transport_type,
"status_code": resp.status_code,
"message": "MCP endpoint server error",
"message": _with_body_preview("MCP endpoint server error", body_preview),
"probe_from": "backend-host",
}
if resp.status_code in {200, 201, 202, 204}:
@ -1213,7 +1225,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False,
"transport": transport_type,
"status_code": resp.status_code,
"message": "Unexpected response from MCP endpoint",
"message": _with_body_preview("Unexpected response from MCP endpoint", body_preview),
"probe_from": "backend-host",
}
except httpx.TimeoutException:

View File

@ -3,7 +3,7 @@ from typing import Any, Dict, Optional
from sqlmodel import Session
from services.topic_service import _topic_publish_internal
from services.topic_service import _has_topic_mcp_server, _topic_publish_internal
from .publisher import build_topic_publish_payload
@ -19,6 +19,8 @@ def publish_runtime_topic_packet(
packet_type = str(packet.get("type") or "").strip().upper()
if packet_type not in {"ASSISTANT_MESSAGE", "BUS_EVENT"} or not persisted_message_id:
return
if not _has_topic_mcp_server(bot_id):
return
topic_payload = build_topic_publish_payload(
bot_id,

View File

@ -151,6 +151,18 @@ def _resolve_topic_mcp_bot_id_by_token(session: Session, token: str) -> Optional
return None
def _has_topic_mcp_server(bot_id: str) -> bool:
config_data = _read_bot_config(bot_id)
tools_cfg = config_data.get("tools")
if not isinstance(tools_cfg, dict):
return False
mcp_servers = tools_cfg.get("mcpServers")
if not isinstance(mcp_servers, dict):
return False
token = _extract_topic_mcp_token(mcp_servers.get(TOPIC_MCP_SERVER_NAME))
return bool(token)
def _normalize_topic_key(raw: Any) -> str:
value = str(raw or "").strip().lower()
if not value:

View File

@ -332,7 +332,8 @@ body {
}
.grid-ops.grid-ops-compact {
grid-template-columns: minmax(0, 1fr) minmax(280px, 420px);
grid-template-columns: minmax(0, 1fr);
grid-template-rows: minmax(0, 1fr);
}
.stack {
@ -1197,12 +1198,12 @@ body {
@media (max-width: 980px) {
.grid-ops.grid-ops-compact {
grid-template-columns: 1fr;
grid-template-rows: auto auto;
grid-template-rows: minmax(0, 1fr);
}
.app-shell-compact .grid-ops.grid-ops-compact {
grid-template-columns: 1fr;
grid-template-rows: minmax(0, 1fr) auto;
grid-template-rows: minmax(0, 1fr);
height: 100%;
min-height: 0;
}

View File

@ -35,8 +35,8 @@ function AuthenticatedApp({
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
useBotsSync(forcedBotId);
const t = pickLocale(locale, { 'zh-cn': appZhCn, en: appEn });
const isSingleBotCompactView = compactMode && Boolean(String(forcedBotId || '').trim());
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView);
const isCompactShell = compactMode;
const [headerCollapsed, setHeaderCollapsed] = useState(isCompactShell);
const forced = String(forcedBotId || '').trim();
const forcedBot = forced ? activeBots[forced] : undefined;
const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked);
@ -51,8 +51,8 @@ function AuthenticatedApp({
}, [forced, forcedBot?.name, t.title]);
useEffect(() => {
setHeaderCollapsed(isSingleBotCompactView);
}, [isSingleBotCompactView, forcedBotId]);
setHeaderCollapsed(isCompactShell);
}, [isCompactShell, forcedBotId]);
useEffect(() => {
setSingleBotUnlocked(false);
@ -114,9 +114,9 @@ function AuthenticatedApp({
<div className={`app-shell ${compactMode ? 'app-shell-compact' : ''}`} data-theme={theme}>
<div className="app-frame">
<header
className={`app-header ${isSingleBotCompactView ? 'app-header-collapsible' : ''} ${isSingleBotCompactView && headerCollapsed ? 'is-collapsed' : ''}`}
className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
onClick={() => {
if (isSingleBotCompactView && headerCollapsed) setHeaderCollapsed(false);
if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
}}
>
<div className="row-between app-header-top">
@ -124,7 +124,7 @@ function AuthenticatedApp({
<img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div className="app-title-main">
<h1>{t.title}</h1>
{isSingleBotCompactView ? (
{isCompactShell ? (
<button
type="button"
className="app-header-toggle-inline"
@ -305,6 +305,19 @@ function PanelLoginGate({
const compactMode = compactByFlag || forcedBotId.length > 0;
return { forcedBotId, compactMode };
}, []);
const [viewportCompact, setViewportCompact] = useState(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return false;
return window.matchMedia('(max-width: 980px)').matches;
});
useEffect(() => {
if (typeof window === 'undefined' || typeof window.matchMedia !== 'function') return;
const media = window.matchMedia('(max-width: 980px)');
const apply = () => setViewportCompact(media.matches);
apply();
media.addEventListener('change', apply);
return () => media.removeEventListener('change', apply);
}, []);
const compactMode = urlView.compactMode || viewportCompact;
const [checking, setChecking] = useState(true);
const [required, setRequired] = useState(false);
@ -427,7 +440,7 @@ function PanelLoginGate({
);
}
return children(urlView);
return children({ forcedBotId: urlView.forcedBotId || undefined, compactMode });
}
function App() {

View File

@ -13,14 +13,30 @@
max-height: 72vh;
}
.app-shell-compact .ops-bot-list {
height: 100%;
min-height: 0;
}
.app-shell-compact .ops-bot-list .list-scroll {
max-height: none;
}
.ops-compact-hidden {
display: none !important;
}
.ops-compact-fab-switch {
.ops-compact-fab-stack {
position: fixed;
right: 14px;
bottom: 14px;
display: grid;
gap: 10px;
z-index: 85;
}
.ops-compact-fab-switch {
position: relative;
width: 48px;
height: 48px;
border-radius: 999px;
@ -33,7 +49,6 @@
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);
@ -423,6 +438,46 @@
flex: 1 1 auto;
}
.ops-compact-bot-surface {
position: fixed;
left: 12px;
right: 12px;
top: 58px;
bottom: 12px;
z-index: 72;
animation: ops-compact-sheet-in 220ms ease;
}
.ops-compact-close-btn {
position: fixed;
top: 18px;
right: 14px;
width: 34px;
height: 34px;
border-radius: 999px;
border: 1px solid color-mix(in oklab, var(--brand) 58%, var(--line) 42%);
background: color-mix(in oklab, var(--panel) 78%, var(--brand-soft) 22%);
color: var(--icon);
display: inline-flex;
align-items: center;
justify-content: center;
z-index: 90;
box-shadow:
0 8px 20px rgba(9, 15, 28, 0.38),
0 0 0 2px color-mix(in oklab, var(--brand) 22%, transparent);
}
@keyframes ops-compact-sheet-in {
from {
transform: translateY(22px);
opacity: 0.82;
}
to {
transform: translateY(0);
opacity: 1;
}
}
.ops-main-content-shell {
display: block;
min-height: 0;

View File

@ -1402,6 +1402,10 @@ export function BotDashboardModule({
}),
[activeBots],
);
const hasForcedBot = Boolean(String(forcedBotId || '').trim());
const compactListFirstMode = compactMode && !hasForcedBot;
const isCompactListPage = compactListFirstMode && !selectedBotId;
const showCompactBotPageClose = compactListFirstMode && Boolean(selectedBotId);
const normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots;
@ -1858,9 +1862,15 @@ export function BotDashboardModule({
}
return;
}
if (compactListFirstMode) {
if (selectedBotId && !activeBots[selectedBotId]) {
setSelectedBotId('');
}
return;
}
if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
if (selectedBotId && !activeBots[selectedBotId] && bots.length > 0) setSelectedBotId(bots[0].id);
}, [bots, selectedBotId, activeBots, forcedBotId]);
}, [bots, selectedBotId, activeBots, forcedBotId, compactListFirstMode]);
useEffect(() => {
setComposerDraftHydrated(false);
@ -4361,7 +4371,7 @@ export function BotDashboardModule({
return (
<>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''}`}>
{!compactMode ? (
{!compactMode || isCompactListPage ? (
<section className="panel stack ops-bot-list">
<div className="row-between">
<h2 style={{ fontSize: 18 }}>
@ -4481,7 +4491,14 @@ export function BotDashboardModule({
const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping';
return (
<div key={bot.id} className={`ops-bot-card ${selected ? 'is-active' : ''}`} onClick={() => setSelectedBotId(bot.id)}>
<div
key={bot.id}
className={`ops-bot-card ${selected ? 'is-active' : ''}`}
onClick={() => {
setSelectedBotId(bot.id);
if (compactMode) setCompactPanelTab('chat');
}}
>
<span className={`ops-bot-strip ${bot.docker_status === 'RUNNING' ? 'is-running' : 'is-stopped'}`} aria-hidden="true" />
<div className="row-between ops-bot-top">
<div className="ops-bot-name-wrap">
@ -4610,7 +4627,7 @@ export function BotDashboardModule({
</section>
) : null}
<section className={`panel ops-chat-panel ${compactMode && isCompactMobile && compactPanelTab !== 'chat' ? 'ops-compact-hidden' : ''}`}>
<section className={`panel ops-chat-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'chat') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-chat-shell">
<div className="ops-main-content-shell">
@ -4906,7 +4923,7 @@ export function BotDashboardModule({
)}
</section>
<section className={`panel stack ops-runtime-panel ${compactMode && isCompactMobile && compactPanelTab !== 'runtime' ? 'ops-compact-hidden' : ''}`}>
<section className={`panel stack ops-runtime-panel ${compactMode && (isCompactListPage || compactPanelTab !== 'runtime') ? 'ops-compact-hidden' : ''} ${showCompactBotPageClose ? 'ops-compact-bot-surface' : ''}`}>
{selectedBot ? (
<div className="ops-runtime-shell">
<div className="row-between ops-runtime-head">
@ -5186,15 +5203,30 @@ export function BotDashboardModule({
)}
</section>
</div>
{compactMode && isCompactMobile ? (
{showCompactBotPageClose ? (
<LucentIconButton
className="ops-compact-close-btn"
onClick={() => {
setSelectedBotId('');
setCompactPanelTab('chat');
}}
tooltip={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
aria-label={isZh ? '关闭并返回 Bot 列表' : 'Close and back to bot list'}
>
<X size={16} />
</LucentIconButton>
) : null}
{compactMode && !isCompactListPage ? (
<div className="ops-compact-fab-stack">
<LucentIconButton
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
onClick={() => 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')}
onClick={() => setCompactPanelTab((v) => (v === 'runtime' ? 'chat' : 'runtime'))}
tooltip={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
aria-label={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
>
{compactPanelTab === 'chat' ? <Activity size={18} /> : <MessageSquareText size={18} />}
{compactPanelTab === 'runtime' ? <MessageSquareText size={18} /> : <Activity size={18} />}
</LucentIconButton>
</div>
) : null}
{showResourceModal && (