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 bot_id:
if transport_type == "sse": if transport_type == "sse":
probe_headers = dict(headers) 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") status_code = probe.get("status_code")
content_type = str(probe.get("content_type") or "") content_type = str(probe.get("content_type") or "")
message = str(probe.get("message") or "").strip() message = str(probe.get("message") or "").strip()
body_preview = probe.get("body_preview")
if status_code in {401, 403}: 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"} 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: 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"} 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: 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"): 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(): 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"} 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) 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") status_code = probe.get("status_code")
message = str(probe.get("message") or "").strip() message = str(probe.get("message") or "").strip()
body_preview = probe.get("body_preview")
if status_code in {401, 403}: 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"} return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "Auth failed for MCP endpoint", "probe_from": "bot-container"}
if status_code == 404: if status_code == 404:
return {"ok": False, "transport": transport_type, "status_code": status_code, "message": "MCP endpoint not found", "probe_from": "bot-container"} 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: 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}: 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)" 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": 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: try:
with httpx.Client(timeout=httpx.Timeout(timeout_s), follow_redirects=True) as client: 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") req_headers.setdefault("Accept", "text/event-stream")
resp = client.get(url, headers=req_headers) resp = client.get(url, headers=req_headers)
content_type = str(resp.headers.get("content-type") or "") content_type = str(resp.headers.get("content-type") or "")
body_preview = resp.text[:512]
if resp.status_code in {401, 403}: if resp.status_code in {401, 403}:
return { return {
"ok": False, "ok": False,
@ -1143,7 +1154,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False, "ok": False,
"transport": transport_type, "transport": transport_type,
"status_code": resp.status_code, "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, "content_type": content_type,
"probe_from": "backend-host", "probe_from": "backend-host",
} }
@ -1152,7 +1163,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False, "ok": False,
"transport": transport_type, "transport": transport_type,
"status_code": resp.status_code, "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, "content_type": content_type,
"probe_from": "backend-host", "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("Content-Type", "application/json")
req_headers.setdefault("Accept", "application/json, text/event-stream") req_headers.setdefault("Accept", "application/json, text/event-stream")
resp = client.post(url, headers=req_headers, json=probe_payload) resp = client.post(url, headers=req_headers, json=probe_payload)
body_preview = resp.text[:512]
if resp.status_code in {401, 403}: if resp.status_code in {401, 403}:
return { return {
"ok": False, "ok": False,
@ -1190,7 +1202,7 @@ def _probe_mcp_server(cfg: Dict[str, Any], bot_id: Optional[str] = None) -> Dict
"ok": False, "ok": False,
"transport": transport_type, "transport": transport_type,
"status_code": resp.status_code, "status_code": resp.status_code,
"message": "MCP endpoint server error", "message": _with_body_preview("MCP endpoint server error", body_preview),
"probe_from": "backend-host", "probe_from": "backend-host",
} }
if resp.status_code in {200, 201, 202, 204}: 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, "ok": False,
"transport": transport_type, "transport": transport_type,
"status_code": resp.status_code, "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", "probe_from": "backend-host",
} }
except httpx.TimeoutException: except httpx.TimeoutException:

View File

@ -3,7 +3,7 @@ from typing import Any, Dict, Optional
from sqlmodel import Session 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 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() packet_type = str(packet.get("type") or "").strip().upper()
if packet_type not in {"ASSISTANT_MESSAGE", "BUS_EVENT"} or not persisted_message_id: if packet_type not in {"ASSISTANT_MESSAGE", "BUS_EVENT"} or not persisted_message_id:
return return
if not _has_topic_mcp_server(bot_id):
return
topic_payload = build_topic_publish_payload( topic_payload = build_topic_publish_payload(
bot_id, bot_id,

View File

@ -151,6 +151,18 @@ def _resolve_topic_mcp_bot_id_by_token(session: Session, token: str) -> Optional
return None 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: def _normalize_topic_key(raw: Any) -> str:
value = str(raw or "").strip().lower() value = str(raw or "").strip().lower()
if not value: if not value:

View File

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

View File

@ -35,8 +35,8 @@ function AuthenticatedApp({
const [singleBotSubmitting, setSingleBotSubmitting] = useState(false); const [singleBotSubmitting, setSingleBotSubmitting] = useState(false);
useBotsSync(forcedBotId); useBotsSync(forcedBotId);
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 isCompactShell = compactMode;
const [headerCollapsed, setHeaderCollapsed] = useState(isSingleBotCompactView); const [headerCollapsed, setHeaderCollapsed] = useState(isCompactShell);
const forced = String(forcedBotId || '').trim(); const forced = String(forcedBotId || '').trim();
const forcedBot = forced ? activeBots[forced] : undefined; const forcedBot = forced ? activeBots[forced] : undefined;
const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked); const shouldPromptSingleBotPassword = Boolean(forced && forcedBot?.has_access_password && !singleBotUnlocked);
@ -51,8 +51,8 @@ function AuthenticatedApp({
}, [forced, forcedBot?.name, t.title]); }, [forced, forcedBot?.name, t.title]);
useEffect(() => { useEffect(() => {
setHeaderCollapsed(isSingleBotCompactView); setHeaderCollapsed(isCompactShell);
}, [isSingleBotCompactView, forcedBotId]); }, [isCompactShell, forcedBotId]);
useEffect(() => { useEffect(() => {
setSingleBotUnlocked(false); setSingleBotUnlocked(false);
@ -114,9 +114,9 @@ function AuthenticatedApp({
<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 <header
className={`app-header ${isSingleBotCompactView ? 'app-header-collapsible' : ''} ${isSingleBotCompactView && headerCollapsed ? 'is-collapsed' : ''}`} className={`app-header ${isCompactShell ? 'app-header-collapsible' : ''} ${isCompactShell && headerCollapsed ? 'is-collapsed' : ''}`}
onClick={() => { onClick={() => {
if (isSingleBotCompactView && headerCollapsed) setHeaderCollapsed(false); if (isCompactShell && headerCollapsed) setHeaderCollapsed(false);
}} }}
> >
<div className="row-between app-header-top"> <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" /> <img src="/app-bot-icon.svg" alt="Nanobot" className="app-title-icon" />
<div className="app-title-main"> <div className="app-title-main">
<h1>{t.title}</h1> <h1>{t.title}</h1>
{isSingleBotCompactView ? ( {isCompactShell ? (
<button <button
type="button" type="button"
className="app-header-toggle-inline" className="app-header-toggle-inline"
@ -305,6 +305,19 @@ function PanelLoginGate({
const compactMode = compactByFlag || forcedBotId.length > 0; const compactMode = compactByFlag || forcedBotId.length > 0;
return { forcedBotId, compactMode }; 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 [checking, setChecking] = useState(true);
const [required, setRequired] = useState(false); const [required, setRequired] = useState(false);
@ -427,7 +440,7 @@ function PanelLoginGate({
); );
} }
return children(urlView); return children({ forcedBotId: urlView.forcedBotId || undefined, compactMode });
} }
function App() { function App() {

View File

@ -13,14 +13,30 @@
max-height: 72vh; 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 { .ops-compact-hidden {
display: none !important; display: none !important;
} }
.ops-compact-fab-switch { .ops-compact-fab-stack {
position: fixed; position: fixed;
right: 14px; right: 14px;
bottom: 14px; bottom: 14px;
display: grid;
gap: 10px;
z-index: 85;
}
.ops-compact-fab-switch {
position: relative;
width: 48px; width: 48px;
height: 48px; height: 48px;
border-radius: 999px; border-radius: 999px;
@ -33,7 +49,6 @@
box-shadow: box-shadow:
0 10px 24px rgba(9, 15, 28, 0.42), 0 10px 24px rgba(9, 15, 28, 0.42),
0 0 0 2px color-mix(in oklab, var(--brand) 22%, transparent); 0 0 0 2px color-mix(in oklab, var(--brand) 22%, transparent);
z-index: 85;
cursor: pointer; cursor: pointer;
overflow: visible; overflow: visible;
transform: translateY(0); transform: translateY(0);
@ -423,6 +438,46 @@
flex: 1 1 auto; 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 { .ops-main-content-shell {
display: block; display: block;
min-height: 0; min-height: 0;

View File

@ -1402,6 +1402,10 @@ export function BotDashboardModule({
}), }),
[activeBots], [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 normalizedBotListQuery = botListQuery.trim().toLowerCase();
const filteredBots = useMemo(() => { const filteredBots = useMemo(() => {
if (!normalizedBotListQuery) return bots; if (!normalizedBotListQuery) return bots;
@ -1858,9 +1862,15 @@ export function BotDashboardModule({
} }
return; return;
} }
if (compactListFirstMode) {
if (selectedBotId && !activeBots[selectedBotId]) {
setSelectedBotId('');
}
return;
}
if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id); if (!selectedBotId && bots.length > 0) setSelectedBotId(bots[0].id);
if (selectedBotId && !activeBots[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(() => { useEffect(() => {
setComposerDraftHydrated(false); setComposerDraftHydrated(false);
@ -4361,7 +4371,7 @@ export function BotDashboardModule({
return ( return (
<> <>
<div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''}`}> <div className={`grid-ops ${compactMode ? 'grid-ops-compact' : ''}`}>
{!compactMode ? ( {!compactMode || isCompactListPage ? (
<section className="panel stack ops-bot-list"> <section className="panel stack ops-bot-list">
<div className="row-between"> <div className="row-between">
<h2 style={{ fontSize: 18 }}> <h2 style={{ fontSize: 18 }}>
@ -4481,7 +4491,14 @@ export function BotDashboardModule({
const isStarting = controlState === 'starting'; const isStarting = controlState === 'starting';
const isStopping = controlState === 'stopping'; const isStopping = controlState === 'stopping';
return ( 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" /> <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="row-between ops-bot-top">
<div className="ops-bot-name-wrap"> <div className="ops-bot-name-wrap">
@ -4610,7 +4627,7 @@ export function BotDashboardModule({
</section> </section>
) : null} ) : 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 ? ( {selectedBot ? (
<div className="ops-chat-shell"> <div className="ops-chat-shell">
<div className="ops-main-content-shell"> <div className="ops-main-content-shell">
@ -4906,7 +4923,7 @@ export function BotDashboardModule({
)} )}
</section> </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 ? ( {selectedBot ? (
<div className="ops-runtime-shell"> <div className="ops-runtime-shell">
<div className="row-between ops-runtime-head"> <div className="row-between ops-runtime-head">
@ -5186,15 +5203,30 @@ export function BotDashboardModule({
)} )}
</section> </section>
</div> </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 <LucentIconButton
className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`} className={`ops-compact-fab-switch ${compactPanelTab === 'chat' ? 'is-chat' : 'is-runtime'}`}
onClick={() => setCompactPanelTab((v) => (v === 'chat' ? 'runtime' : 'chat'))} onClick={() => setCompactPanelTab((v) => (v === 'runtime' ? 'chat' : 'runtime'))}
tooltip={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')} tooltip={compactPanelTab === 'runtime' ? (isZh ? '切换到对话面板' : 'Switch to chat') : (isZh ? '切换到运行面板' : 'Switch to runtime')}
aria-label={compactPanelTab === 'chat' ? (isZh ? '切换到运行面板' : 'Switch to runtime') : (isZh ? '切换到对话面板' : 'Switch to chat')} 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> </LucentIconButton>
</div>
) : null} ) : null}
{showResourceModal && ( {showResourceModal && (