1011 lines
34 KiB
TypeScript
1011 lines
34 KiB
TypeScript
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>
|
||
);
|
||
}
|