Files
sproutclaw/.pi/agent/extensions/webui/frontend/src/routes/SettingsPage.tsx
shumengya d51a055d78
Some checks failed
CI / build-check-test (push) Has been cancelled
npm audit / audit (push) Has been cancelled
feat: update webui extensions, models, and agent config
2026-06-10 21:45:53 +08:00

1011 lines
34 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { useCallback, useEffect, useState } from "react";
import { Link } from "react-router-dom";
import * as settingsApi from "../api/settings";
import { useAvatars } from "../context/AvatarContext";
import { useBootstrap } from "../context/BootstrapContext";
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events";
import styles from "./SettingsPage.module.css";
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "env"];
function isNpmSkill(skill: SkillInfo): boolean {
if (skill.toggleable === false) return true;
const source = skill.source || "";
if (source.startsWith("npm:")) return true;
const path = skill.path || "";
return path.includes("/node_modules/") || path.includes("\\node_modules\\");
}
function isSkillToggleable(skill: SkillInfo): boolean {
return !isNpmSkill(skill) && skill.toggleable !== false;
}
function formatSkillMeta(skill: SkillInfo): string {
const source = (skill.source || "").trim();
if (!source || source === "auto") return "";
return source;
}
const SETTINGS_PANE_KEY = "sproutclaw-settings-pane";
function renderNameList(label: string, items: string[] | undefined) {
if (!Array.isArray(items) || !items.length) return null;
return (
<div className={styles.nameList}>
<span>{label}</span>
<div>
{items.map((item) => (
<code key={item}>{item}</code>
))}
</div>
</div>
);
}
function extensionCategory(extension: ExtensionInfo): "local" | "npm" {
if (extension.category === "npm" || extension.category === "local") {
return extension.category;
}
if (extension.source?.startsWith("npm:")) return "npm";
const pathHint = `${extension.path || ""} ${extension.location || ""}`.replace(/\\/g, "/");
if (pathHint.includes("/.pi/agent/npm/node_modules/")) return "npm";
return "local";
}
function extensionTogglePath(extension: ExtensionInfo): string {
return extension.resolvedPath || extension.path || "";
}
function ExtensionCard({
extension,
toggling,
disabled,
onToggle,
}: {
extension: ExtensionInfo;
toggling: boolean;
disabled: boolean;
onToggle: (enabled: boolean) => void;
}) {
const meta =
extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
const enabled = extension.enabled !== false;
const togglePath = extensionTogglePath(extension);
const counts = (
[
["命令", extension.commands?.length || 0],
["工具", extension.tools?.length || 0],
["事件", extension.handlers?.length || 0],
["Flags", extension.flags?.length || 0],
["快捷键", extension.shortcuts?.length || 0],
] as const
).filter(([, count]) => count > 0);
const hasDetails = ["commands", "tools", "handlers", "flags", "shortcuts"].some(
(key) => (extension[key as keyof ExtensionInfo] as string[] | undefined)?.length,
);
return (
<div className={`${styles.extensionItem} ${enabled ? "" : styles.skillItemDisabled}`}>
<div className={styles.extensionHeader}>
<div className={styles.extensionTitleRow}>
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
<label className={styles.skillToggle}>
<input
type="checkbox"
checked={enabled}
disabled={!togglePath || toggling || disabled}
onChange={(e) => {
if (!togglePath) return;
onToggle(e.target.checked);
}}
/>
<span className={styles.skillToggleUi} aria-hidden />
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
</label>
</div>
{meta ? <div className={styles.extensionSubtitle}>{meta}</div> : null}
</div>
{counts.length ? (
<div className={styles.extensionCounts}>
{counts.map(([label, count]) => (
<span key={label}>
{label} {count}
</span>
))}
</div>
) : null}
{hasDetails ? (
<details className={styles.extensionDetails}>
<summary></summary>
{renderNameList("命令", extension.commands)}
{renderNameList("工具", extension.tools)}
{renderNameList("事件", extension.handlers)}
{renderNameList("Flags", extension.flags)}
{renderNameList("快捷键", extension.shortcuts)}
</details>
) : null}
{extension.location ? <div className={styles.itemPath}>{extension.location}</div> : null}
</div>
);
}
function ExtensionGroup({
title,
items,
emptyText,
togglingPath,
disabled,
onToggle,
}: {
title: string;
items: ExtensionInfo[];
emptyText: string;
togglingPath: string | null;
disabled: boolean;
onToggle: (path: string, enabled: boolean) => void;
}) {
return (
<section className={styles.extensionGroup}>
<div className={styles.extensionGroupHeader}>
<h3>{title}</h3>
<span>{items.length} </span>
</div>
{items.length ? (
<div className={styles.extensionGroupList}>
{items.map((extension) => {
const togglePath = extensionTogglePath(extension);
return (
<ExtensionCard
key={togglePath || extension.location || extension.name}
extension={extension}
toggling={Boolean(togglePath && togglingPath === togglePath)}
disabled={disabled}
onToggle={(enabled) => {
if (!togglePath) return;
onToggle(togglePath, enabled);
}}
/>
);
})}
</div>
) : (
<div className={`${styles.empty} ${styles.emptyCompact}`}>{emptyText}</div>
)}
</section>
);
}
function McpToolItem({
tool,
serverEnabled,
toggling,
disabled,
onToggle,
}: {
tool: McpToolInfo;
serverEnabled: boolean;
toggling: boolean;
disabled: boolean;
onToggle: (enabled: boolean) => void;
}) {
const params = Array.isArray(tool.parameters) ? tool.parameters : [];
const required = new Set(Array.isArray(tool.required) ? tool.required : []);
const description = String(tool.description || "").replace(/\s+/g, " ").trim();
const shortDescription =
description.length > 86 ? `${description.slice(0, 86).trimEnd()}...` : description;
const title = [tool.name, description, params.length ? `参数: ${params.join(", ")}` : ""]
.filter(Boolean)
.join("\n");
const enabled = tool.enabled !== false;
const toolName = tool.name || "";
return (
<details
className={`${styles.mcpToolItem} ${enabled ? "" : styles.mcpToolItemDisabled}`}
title={title}
>
<summary className={styles.mcpToolSummary}>
<span className={styles.mcpToolName}>{toolName || "未命名工具"}</span>
<div className={styles.mcpToolSummaryActions}>
{params.length ? <span className={styles.badge}>{params.length} </span> : null}
<label
className={styles.skillToggle}
onClick={(e) => e.stopPropagation()}
onKeyDown={(e) => e.stopPropagation()}
>
<input
type="checkbox"
checked={enabled}
disabled={!toolName || !serverEnabled || toggling || disabled}
onChange={(e) => {
if (!toolName || !serverEnabled) return;
onToggle(e.target.checked);
}}
/>
<span className={styles.skillToggleUi} aria-hidden />
<span className={styles.skillToggleLabel}>{enabled ? "开" : "关"}</span>
</label>
</div>
</summary>
{shortDescription ? <div className={styles.mcpToolDesc}>{shortDescription}</div> : null}
{params.length ? (
<div className={styles.mcpParamList}>
{params.map((param) => (
<code key={param}>
{param}
{required.has(param) ? "*" : ""}
</code>
))}
</div>
) : null}
</details>
);
}
export function SettingsPage() {
const { markRouteReady } = useBootstrap();
const { updateAvatars } = useAvatars();
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
const [systemPrompt, setSystemPrompt] = useState("");
const [systemPromptPath, setSystemPromptPath] = useState("");
const [modelsConfig, setModelsConfig] = useState("");
const [modelsConfigPath, setModelsConfigPath] = useState("");
const [userAvatarUrl, setUserAvatarUrl] = useState("");
const [agentAvatarUrl, setAgentAvatarUrl] = useState("");
const [skills, setSkills] = useState<SkillInfo[]>([]);
const [mcpTools, setMcpTools] = useState<McpServerInfo[]>([]);
const [extensions, setExtensions] = useState<ExtensionInfo[]>([]);
const [extensionsPath, setExtensionsPath] = useState("");
const [statusText, setStatusText] = useState("");
const [statusKind, setStatusKind] = useState<"" | "ok" | "error">("");
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [savingModels, setSavingModels] = useState(false);
const [savingAvatars, setSavingAvatars] = useState(false);
const [reloading, setReloading] = useState(false);
const [togglingSkillPath, setTogglingSkillPath] = useState<string | null>(null);
const [togglingMcpServer, setTogglingMcpServer] = useState<string | null>(null);
const [togglingMcpTool, setTogglingMcpTool] = useState<string | null>(null);
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
const [loadingEnv, setLoadingEnv] = useState(false);
useEffect(() => {
let initial: SettingsPaneId = "prompt";
try {
const saved = sessionStorage.getItem(SETTINGS_PANE_KEY);
if (saved && PANE_IDS.includes(saved as SettingsPaneId)) {
initial = saved as SettingsPaneId;
}
} catch {
/* ignore */
}
setActivePane(initial);
}, []);
const setStatus = (text: string, kind: "" | "ok" | "error" = "") => {
setStatusText(text);
setStatusKind(kind);
};
const showPane = (paneId: SettingsPaneId) => {
setActivePane(paneId);
try {
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
} catch {
/* ignore */
}
};
const loadSettings = useCallback(async () => {
setLoading(true);
setStatus("");
try {
const data = await settingsApi.fetchSettings();
setSystemPrompt(data.systemPrompt || "");
setSystemPromptPath(data.systemPromptPath || "");
setModelsConfig(data.modelsConfig || "");
setModelsConfigPath(data.modelsConfigPath || "");
setUserAvatarUrl(data.userAvatarUrl || "");
setAgentAvatarUrl(data.agentAvatarUrl || "");
updateAvatars({
userAvatarUrl: data.userAvatarUrl || "",
agentAvatarUrl: data.agentAvatarUrl || "",
});
setSkills(data.skills || []);
setMcpTools(data.mcpTools || []);
setExtensions(data.extensions || []);
setExtensionsPath(data.extensionsPath || "");
} catch (err) {
setStatus(`加载设置失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoading(false);
markRouteReady("settings");
}
}, [markRouteReady, updateAvatars]);
const reloadSettings = async () => {
setReloading(true);
setStatus("正在重新加载 Agent 资源...");
try {
await settingsApi.reloadSettings();
await loadSettings();
setStatus("已重新加载并刷新设置", "ok");
} catch (err) {
setStatus(`重新加载失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setReloading(false);
}
};
const loadEnvInfo = async () => {
setLoadingEnv(true);
try {
const data = await settingsApi.fetchEnvironment();
setEnvInfo(data);
} catch (err) {
setStatus(`加载环境信息失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoadingEnv(false);
}
};
useEffect(() => {
if (activePane === "env" && !envInfo && !loadingEnv) {
void loadEnvInfo();
}
}, [activePane]);
const toggleSkillEnabled = async (path: string, enabled: boolean) => {
setTogglingSkillPath(path);
setStatus(enabled ? "正在启用 skill…" : "正在禁用 skill…");
try {
const data = await settingsApi.toggleSkill(path, enabled);
if (data.skill) {
setSkills((prev) =>
prev.map((skill) => (skill.path === path ? { ...skill, ...data.skill } : skill)),
);
} else {
await loadSettings();
}
setStatus(enabled ? "Skill 已启用" : "Skill 已禁用", "ok");
} catch (err) {
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setTogglingSkillPath(null);
}
};
const updateMcpServer = (entry: McpServerInfo) => {
setMcpTools((prev) =>
prev.map((server) => (server.name === entry.name ? { ...server, ...entry } : server)),
);
};
const toggleMcpServerEnabled = async (serverName: string, enabled: boolean) => {
setTogglingMcpServer(serverName);
setStatus(enabled ? "正在启用 MCP Server…" : "正在禁用 MCP Server…");
try {
const data = await settingsApi.toggleMcpServer(serverName, enabled);
if (data.server) {
updateMcpServer(data.server);
} else {
await loadSettings();
}
setStatus(
enabled
? "MCP Server 已启用,必要时可运行 /mcp reconnect 刷新连接"
: "MCP Server 已禁用",
"ok",
);
} catch (err) {
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setTogglingMcpServer(null);
}
};
const toggleMcpToolEnabled = async (serverName: string, toolName: string, enabled: boolean) => {
const toggleKey = `${serverName}/${toolName}`;
setTogglingMcpTool(toggleKey);
setStatus(enabled ? "正在启用 MCP工具…" : "正在禁用 MCP工具…");
try {
const data = await settingsApi.toggleMcpTool(serverName, toolName, enabled);
if (data.tool) {
setMcpTools((prev) =>
prev.map((server) => {
if (server.name !== serverName || !Array.isArray(server.tools)) return server;
const tools = server.tools.map((tool) =>
tool.name === toolName ? { ...tool, ...data.tool } : tool,
);
const enabledToolCount = tools.filter((tool) => tool.enabled !== false).length;
return { ...server, tools, enabledToolCount };
}),
);
} else {
await loadSettings();
}
setStatus(enabled ? "MCP工具已启用" : "MCP工具已禁用", "ok");
} catch (err) {
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setTogglingMcpTool(null);
}
};
const toggleExtensionEnabled = async (path: string, enabled: boolean) => {
setTogglingExtensionPath(path);
setStatus(enabled ? "正在启用扩展…" : "正在禁用扩展…");
try {
const data = await settingsApi.toggleExtension(path, enabled);
if (data.extension) {
setExtensions((prev) =>
prev.map((extension) => {
const key = extensionTogglePath(extension);
return key === path ? { ...extension, ...data.extension } : extension;
}),
);
} else {
await loadSettings();
}
setStatus(enabled ? "扩展已启用Agent 已重新加载" : "扩展已禁用Agent 已重新加载", "ok");
} catch (err) {
setStatus(`切换失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setTogglingExtensionPath(null);
}
};
const saveSystemPrompt = async () => {
setSaving(true);
setStatus("保存中...");
try {
const data = await settingsApi.saveSystemPrompt(systemPrompt);
if (data.systemPromptPath) setSystemPromptPath(data.systemPromptPath);
setStatus("已保存Agent 已重新加载", "ok");
} catch (err) {
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setSaving(false);
}
};
const saveModelsConfig = async () => {
setSavingModels(true);
setStatus("保存中...");
try {
const data = await settingsApi.saveModelsConfig(modelsConfig);
if (data.modelsConfigPath) setModelsConfigPath(data.modelsConfigPath);
const refreshed = await settingsApi.fetchSettings();
setModelsConfig(refreshed.modelsConfig || "");
setStatus("已保存Agent 已重新加载", "ok");
} catch (err) {
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setSavingModels(false);
}
};
const saveAvatars = async () => {
setSavingAvatars(true);
setStatus("保存中...");
try {
const data = await settingsApi.saveAvatars({ userAvatarUrl, agentAvatarUrl });
const next = {
userAvatarUrl: data.userAvatarUrl || "",
agentAvatarUrl: data.agentAvatarUrl || "",
};
setUserAvatarUrl(next.userAvatarUrl);
setAgentAvatarUrl(next.agentAvatarUrl);
updateAvatars(next);
setStatus("头像设置已保存", "ok");
} catch (err) {
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setSavingAvatars(false);
}
};
useEffect(() => {
void loadSettings();
}, [loadSettings]);
const toolTotal = mcpTools.reduce((sum, server) => sum + (server.toolCount || 0), 0);
const enabledMcpServerCount = mcpTools.filter((server) => server.enabled !== false).length;
const enabledMcpToolCount = mcpTools.reduce(
(sum, server) => sum + (server.enabled !== false ? server.enabledToolCount ?? 0 : 0),
0,
);
const localExtensions = extensions.filter((extension) => extensionCategory(extension) === "local");
const npmExtensions = extensions.filter((extension) => extensionCategory(extension) === "npm");
const toggleableSkills = skills.filter(isSkillToggleable);
const enabledSkillCount = toggleableSkills.filter((skill) => skill.enabled !== false).length;
const enabledExtensionCount = extensions.filter((extension) => extension.enabled !== false).length;
const statusClass =
statusKind === "ok" ? styles.statusOk : statusKind === "error" ? styles.statusError : "";
return (
<div className={styles.page}>
<main className={styles.main}>
<header className={styles.pageHeader}>
<div className={styles.pageTitle}>
<img className={styles.logo} src="/logo.png" width={34} height={34} alt="" decoding="async" />
<div>
<h1></h1>
</div>
</div>
<Link to="/" className={`${styles.secondaryBtn} ${styles.linkBtn}`}>
</Link>
</header>
<div className={styles.shell}>
<div className={styles.layout}>
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
{(
[
["prompt", "系统提示词"],
["models", "模型配置"],
["skills", "Skills"],
["mcp", "MCP工具"],
["extensions", "插件扩展"],
["other", "其他设置"],
["env", "环境信息"],
] as const
).map(([id, label]) => (
<button
key={id}
type="button"
role="tab"
aria-selected={activePane === id}
className={`${styles.navBtn} ${activePane === id ? styles.navBtnActive : ""}`}
onClick={() => showPane(id)}
>
{label}
</button>
))}
</nav>
<div className={styles.panes}>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "prompt" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "prompt"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>{systemPromptPath}</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={saving}
onClick={() => void saveSystemPrompt()}
>
</button>
</div>
<textarea
className={styles.textarea}
spellCheck={false}
aria-label="系统提示词"
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
/>
{statusText && activePane === "prompt" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "models" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "models"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>{modelsConfigPath}</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={savingModels}
onClick={() => void saveModelsConfig()}
>
</button>
</div>
<textarea
className={styles.textarea}
spellCheck={false}
aria-label="模型配置"
value={modelsConfig}
onChange={(e) => setModelsConfig(e.target.value)}
/>
{statusText && activePane === "models" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "skills" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "skills"}
>
<div className={styles.panelHeader}>
<div>
<h2>Skills</h2>
<p>
{enabledSkillCount} / {toggleableSkills.length}
</p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={reloading}
onClick={() => void reloadSettings()}
>
</button>
</div>
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>...</div>
) : !skills.length ? (
<div className={styles.empty}> skills</div>
) : (
skills.map((skill) => {
const meta = formatSkillMeta(skill);
const skillPath = skill.path || "";
const enabled = skill.enabled !== false;
const toggleable = isSkillToggleable(skill);
const toggling = togglingSkillPath === skillPath;
return (
<div
key={skillPath || skill.name || skill.command}
className={`${styles.skillItem} ${enabled ? "" : styles.skillItemDisabled}`}
>
<div className={styles.titleRow}>
<div className={styles.itemName}>
{skill.name || skill.command || "未命名"}
</div>
<div className={styles.skillActions}>
{meta ? <div className={styles.itemMeta}>{meta}</div> : null}
{toggleable ? (
<label className={styles.skillToggle}>
<input
type="checkbox"
checked={enabled}
disabled={!skillPath || toggling || reloading}
onChange={(e) => {
if (!skillPath) return;
void toggleSkillEnabled(skillPath, e.target.checked);
}}
/>
<span className={styles.skillToggleUi} aria-hidden />
<span className={styles.skillToggleLabel}>
{enabled ? "已启用" : "已禁用"}
</span>
</label>
) : null}
</div>
</div>
{skill.description ? (
<div className={styles.itemDesc}>{skill.description}</div>
) : null}
{skill.path ? <div className={styles.itemPath}>{skill.path}</div> : null}
</div>
);
})
)}
</div>
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "mcp" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "mcp"}
>
<div className={styles.panelHeader}>
<div>
<h2>MCP工具</h2>
<p>
{enabledMcpServerCount} / {mcpTools.length} Server · {enabledMcpToolCount} /{" "}
{toolTotal}
</p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={reloading}
onClick={() => void reloadSettings()}
>
</button>
</div>
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>...</div>
) : !mcpTools.length ? (
<div className={styles.empty}> MCP server </div>
) : (
mcpTools.map((server) => {
const tools = Array.isArray(server.tools) ? server.tools : [];
const serverName = server.name || "";
const serverEnabled = server.enabled !== false;
const togglingServer = togglingMcpServer === serverName;
const status = server.cached ? "已缓存" : "未连接";
const cachedAt = server.cachedAt
? new Date(server.cachedAt).toLocaleString()
: "";
return (
<div
key={serverName || "unnamed"}
className={`${styles.mcpServerItem} ${serverEnabled ? "" : styles.skillItemDisabled}`}
>
<div className={styles.mcpServerHead}>
<div className={styles.mcpServerMain}>
<div className={styles.titleRow}>
<div className={styles.itemName}>
{serverName || "未命名 Server"}
</div>
<label className={styles.skillToggle}>
<input
type="checkbox"
checked={serverEnabled}
disabled={!serverName || togglingServer || reloading}
onChange={(e) => {
if (!serverName) return;
void toggleMcpServerEnabled(serverName, e.target.checked);
}}
/>
<span className={styles.skillToggleUi} aria-hidden />
<span className={styles.skillToggleLabel}>
{serverEnabled ? "已启用" : "已禁用"}
</span>
</label>
</div>
{cachedAt ? (
<div className={styles.mcpServerSub}> {cachedAt}</div>
) : null}
</div>
<div className={styles.mcpServerBadges}>
<span className={styles.badge}>{status}</span>
<span className={styles.badge}>
{serverEnabled
? `${server.enabledToolCount ?? tools.filter((tool) => tool.enabled !== false).length} / ${server.toolCount || tools.length} 工具`
: `${server.toolCount || tools.length} 工具`}
</span>
{server.resourceCount ? (
<span className={styles.badge}>{server.resourceCount} </span>
) : null}
</div>
</div>
{tools.length ? (
<div className={styles.mcpToolList}>
{tools.map((tool) => {
const toolName = tool.name || "";
const toggleKey = `${serverName}/${toolName}`;
return (
<McpToolItem
key={toolName || "unnamed"}
tool={tool}
serverEnabled={serverEnabled}
toggling={togglingMcpTool === toggleKey}
disabled={reloading || togglingServer}
onToggle={(enabled) => {
if (!serverName || !toolName) return;
void toggleMcpToolEnabled(serverName, toolName, enabled);
}}
/>
);
})}
</div>
) : (
<div className={`${styles.empty} ${styles.emptyCompact}`}>
/mcp reconnect
</div>
)}
</div>
);
})
)}
</div>
{statusText && activePane === "mcp" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "extensions" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "extensions"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>
{enabledExtensionCount} / {extensions.length} · {localExtensions.length}{" "}
· {npmExtensions.length} npm
{extensionsPath ? ` · ${extensionsPath}` : ""}
</p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={reloading}
onClick={() => void reloadSettings()}
>
</button>
</div>
<div className={styles.list}>
{loading ? (
<div className={styles.empty}>...</div>
) : !extensions.length ? (
<div className={styles.empty}></div>
) : (
<>
<ExtensionGroup
title="本地扩展"
items={localExtensions}
emptyText="暂无本地扩展"
togglingPath={togglingExtensionPath}
disabled={reloading}
onToggle={(path, enabled) => void toggleExtensionEnabled(path, enabled)}
/>
<ExtensionGroup
title="npm 扩展"
items={npmExtensions}
emptyText="暂无 npm 扩展"
togglingPath={togglingExtensionPath}
disabled={reloading}
onToggle={(path, enabled) => void toggleExtensionEnabled(path, enabled)}
/>
</>
)}
</div>
{statusText && activePane === "extensions" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "other" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "other"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>使</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={savingAvatars}
onClick={() => void saveAvatars()}
>
</button>
</div>
<div className={styles.formBody}>
<label className={styles.field}>
<span className={styles.fieldLabel}></span>
<input
className={styles.textInput}
type="url"
inputMode="url"
placeholder="https://example.com/user-avatar.png"
value={userAvatarUrl}
onChange={(e) => setUserAvatarUrl(e.target.value)}
/>
{userAvatarUrl.trim() ? (
<div className={styles.avatarPreviewRow}>
<img
className={styles.avatarPreview}
src={userAvatarUrl.trim()}
alt=""
decoding="async"
/>
<span className={styles.previewHint}></span>
</div>
) : null}
</label>
<label className={styles.field}>
<span className={styles.fieldLabel}>Agent </span>
<input
className={styles.textInput}
type="url"
inputMode="url"
placeholder="https://example.com/agent-avatar.png"
value={agentAvatarUrl}
onChange={(e) => setAgentAvatarUrl(e.target.value)}
/>
{agentAvatarUrl.trim() ? (
<div className={styles.avatarPreviewRow}>
<img
className={styles.avatarPreview}
src={agentAvatarUrl.trim()}
alt=""
decoding="async"
/>
<span className={styles.previewHint}></span>
</div>
) : null}
</label>
<p className={styles.fieldHint}>
http / https
</p>
</div>
{statusText && activePane === "other" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "env" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "env"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>Node.js · · </p>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={loadingEnv}
onClick={() => void loadEnvInfo()}
>
</button>
</div>
<div className={styles.formBody}>
{loadingEnv ? (
<div className={styles.empty}>...</div>
) : !envInfo ? (
<div className={styles.empty}></div>
) : (
<div className={styles.envGrid}>
{(
[
["Node.js 版本", envInfo.nodeVersion],
["平台", envInfo.platform],
["架构", envInfo.arch],
["系统类型", envInfo.osType],
["系统版本", envInfo.osRelease],
["主机名", envInfo.hostname],
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
["运行时长", envInfo.uptime !== undefined ? envInfo.uptime + " 秒" : undefined],
["总内存", envInfo.totalMemMb !== undefined ? envInfo.totalMemMb + " MB" : undefined],
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
["Node 路径", envInfo.execPath],
["工作目录", envInfo.cwd],
] as [string, string | undefined][]
)
.filter(([, v]) => v !== undefined && v !== "")
.map(([label, value]) => (
<div key={label} className={styles.envRow}>
<span className={styles.envLabel}>{label}</span>
<code className={styles.envValue}>{value}</code>
</div>
))}
</div>
)}
</div>
{statusText && activePane === "env" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
</div>
</div>
</div>
</main>
</div>
);
}