Files
sproutclaw-web/frontend/src/routes/SettingsPage.tsx
shumengya 33717d742b feat: 增强聊天 UI 与终端配置
- 重构消息列表、思考块与工具调用块样式
- ChatContext 支持 agent 分段与 markdown 渲染优化
- 后端增加 PI 命令配置与 Web 终端改进
- 新增 run-backend.sh 启动脚本,忽略 backend/dist 构建产物
2026-06-26 13:21:15 +08:00

1533 lines
53 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 { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import * as promptsApi from "../api/prompts";
import type { PromptFile } from "../api/prompts";
import * as settingsApi from "../api/settings";
import { useAvatars } from "../context/AvatarContext";
import { useChatContext } from "../context/ChatContext";
import type { AgentToolInfo, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, RuntimeInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events";
import { renderMarkdown } from "../utils/markdown";
import styles from "./SettingsPage.module.css";
const WebTerminal = lazy(() =>
import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })),
);
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "prompts", "other", "terminal", "env"];
function formatUptime(seconds: number): string {
if (seconds < 60) return `${seconds}`;
const m = Math.floor(seconds / 60) % 60;
const h = Math.floor(seconds / 3600) % 24;
const d = Math.floor(seconds / 86400);
if (d > 0) return `${d}${h}${m}`;
if (h > 0) return `${h}${m}`;
return `${m}`;
}
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 validateModelsConfigJson(text: string): string | null {
const trimmed = text.trim();
if (!trimmed) {
return "JSON 不能为空";
}
let parsed: unknown;
try {
parsed = JSON.parse(trimmed);
} catch (err) {
const message = err instanceof SyntaxError ? err.message : String(err);
return `JSON 语法错误:${message}`;
}
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) {
return "模型配置必须是 JSON 对象";
}
const root = parsed as Record<string, unknown>;
if (!("providers" in root)) {
return '缺少必填字段 "providers"';
}
if (!root.providers || typeof root.providers !== "object" || Array.isArray(root.providers)) {
return '"providers" 必须是对象';
}
for (const [name, provider] of Object.entries(root.providers as Record<string, unknown>)) {
if (!provider || typeof provider !== "object" || Array.isArray(provider)) {
return `"providers.${name}" 必须是对象`;
}
}
return null;
}
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("/agent/npm/node_modules/")) return "npm";
return "local";
}
function extensionTogglePath(extension: ExtensionInfo): string {
return extension.resolvedPath || extension.path || "";
}
// Only local extensions can be enabled/disabled (by moving the directory between
// extensions/ and extensions-disabled/). npm extensions are not managed here.
function extensionToggleable(extension: ExtensionInfo): boolean {
if (extension.toggleable === false) return false;
return extensionCategory(extension) === "local";
}
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 toggleable = extensionToggleable(extension);
const togglePath = extensionTogglePath(extension);
const versionLabel = extension.version ? `v${extension.version}` : "";
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>
{toggleable ? (
<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>
) : versionLabel ? (
<div className={styles.itemMeta}>{versionLabel}</div>
) : null}
</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 { updateAvatars } = useAvatars();
const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext();
const [activePane, setActivePane] = useState<SettingsPaneId>("prompt");
const [systemPrompt, setSystemPrompt] = useState("");
const [systemPromptPath, setSystemPromptPath] = useState("");
const [systemPromptPreview, setSystemPromptPreview] = useState(false);
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 [restarting, setRestarting] = useState(false);
// 命令模板 state
const [promptList, setPromptList] = useState<PromptFile[]>([]);
const [loadingPrompts, setLoadingPrompts] = useState(false);
const [promptEditMode, setPromptEditMode] = useState<"list" | "edit">("list");
const [promptFilename, setPromptFilename] = useState(""); // .md 后缀的文件名
const [promptOriginalName, setPromptOriginalName] = useState(""); // 编辑前的原始文件名(空串表示新建)
const [promptContent, setPromptContent] = useState("");
const [promptPreview, setPromptPreview] = useState(false);
const [savingPrompt, setSavingPrompt] = useState(false);
const [deletingPrompt, setDeletingPrompt] = 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);
const [toolsInfo, setToolsInfo] = useState<EnvironmentToolsInfo | null>(null);
const [loadingTools, setLoadingTools] = useState(false);
const [terminalPlatform, setTerminalPlatform] = useState("");
const [terminalRequested, setTerminalRequested] = useState(false);
const sidebarRef = useRef<HTMLElement>(null);
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);
if (paneId !== "prompt") {
setSystemPromptPreview(false);
}
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);
}
}, [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 loadTerminalInfo = async () => {
try {
const data = await settingsApi.fetchTerminalInfo();
if (data.platform) {
setTerminalPlatform(data.platform);
}
} catch (err) {
setStatus(`加载终端信息失败: ${err instanceof Error ? err.message : String(err)}`, "error");
}
};
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);
}
};
const loadToolsInfo = async () => {
setLoadingTools(true);
try {
const data = await settingsApi.fetchEnvironmentTools();
setToolsInfo(data);
} catch (err) {
setStatus(`检测工具失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoadingTools(false);
}
};
const loadPrompts = async () => {
setLoadingPrompts(true);
try {
const data = await promptsApi.fetchPrompts();
setPromptList(data.prompts || []);
} catch (err) {
setStatus(`加载命令模板失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setLoadingPrompts(false);
}
};
const openPromptEditor = async (file: PromptFile) => {
try {
const data = await promptsApi.fetchPromptFile(file.name);
setPromptOriginalName(file.name);
setPromptFilename(file.name);
setPromptContent(data.content);
setPromptPreview(false);
setPromptEditMode("edit");
} catch (err) {
setStatus(`加载文件失败: ${err instanceof Error ? err.message : String(err)}`, "error");
}
};
const openNewPrompt = () => {
setPromptOriginalName("");
setPromptFilename("新模板.md");
setPromptContent("");
setPromptPreview(false);
setPromptEditMode("edit");
};
const savePrompt = async () => {
let name = promptFilename.trim();
if (!name) { setStatus("文件名不能为空", "error"); return; }
if (!name.endsWith(".md")) name += ".md";
setSavingPrompt(true);
setStatus("保存中...");
try {
await promptsApi.savePromptFile(name, promptContent);
// 如果改名了,删除旧文件
if (promptOriginalName && promptOriginalName !== name) {
await promptsApi.deletePromptFile(promptOriginalName);
}
setStatus("已保存", "ok");
setPromptOriginalName(name);
setPromptFilename(name);
await loadPrompts();
} catch (err) {
setStatus(`保存失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setSavingPrompt(false);
}
};
const deletePrompt = async () => {
if (!promptOriginalName) {
setPromptEditMode("list");
return;
}
if (!confirm(`确认删除「${promptOriginalName}」?`)) return;
setDeletingPrompt(true);
try {
await promptsApi.deletePromptFile(promptOriginalName);
setStatus("已删除", "ok");
setPromptEditMode("list");
await loadPrompts();
} catch (err) {
setStatus(`删除失败: ${err instanceof Error ? err.message : String(err)}`, "error");
} finally {
setDeletingPrompt(false);
}
};
useEffect(() => {
if (activePane === "env") {
if (!envInfo && !loadingEnv) void loadEnvInfo();
if (!toolsInfo && !loadingTools) void loadToolsInfo();
}
if (activePane === "prompts" && promptList.length === 0 && !loadingPrompts) {
void loadPrompts();
}
if (activePane !== "prompts") {
setPromptEditMode("list");
}
if (activePane === "terminal" && !terminalPlatform) {
void loadTerminalInfo();
}
if (activePane === "terminal") {
setTerminalRequested(true);
}
}, [activePane]);
useEffect(() => {
const activeTab = sidebarRef.current?.querySelector<HTMLElement>('[aria-selected="true"]');
activeTab?.scrollIntoView({ behavior: "smooth", inline: "nearest", block: "nearest" });
}, [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 () => {
const validationError = validateModelsConfigJson(modelsConfig);
if (validationError) {
setStatus(validationError, "error");
return;
}
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 restartBackend = async () => {
setRestarting(true);
setStatus("正在重启后端...");
try {
await settingsApi.restartBackend();
} catch {
// 后端重启时连接断开属正常现象,忽略错误
}
const deadline = Date.now() + 60_000;
const poll = async () => {
if (Date.now() > deadline) {
setRestarting(false);
setStatus("后端重启超时,请检查 sproutclaw-web 服务状态", "error");
return;
}
try {
await settingsApi.fetchSettings();
setStatus("后端已重启,正在刷新...", "ok");
setTimeout(() => window.location.reload(), 800);
} catch {
setTimeout(poll, 1000);
}
};
setTimeout(poll, 1500);
};
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 modelsConfigError = useMemo(() => validateModelsConfigJson(modelsConfig), [modelsConfig]);
const systemPromptHtml = useMemo(
() => (systemPromptPreview ? renderMarkdown(systemPrompt) : ""),
[systemPromptPreview, systemPrompt],
);
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} ref={sidebarRef} role="tablist" aria-label="设置分区">
{(
[
["prompt", "系统提示词"],
["models", "模型配置"],
["skills", "Skills"],
["mcp", "MCP工具"],
["extensions", "插件扩展"],
["prompts", "命令模板"],
["other", "其他设置"],
["terminal", "终端"],
["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>
<div className={styles.panelHeaderActions}>
<button
type="button"
className={`${styles.secondaryBtn} ${systemPromptPreview ? styles.headerBtnActive : ""}`}
onClick={() => setSystemPromptPreview((preview) => !preview)}
>
{systemPromptPreview ? "编辑" : "预览"}
</button>
<button
type="button"
className={styles.primaryBtn}
disabled={saving}
onClick={() => void saveSystemPrompt()}
>
</button>
</div>
</div>
{systemPromptPreview ? (
<div className={styles.markdownPreview}>
{systemPrompt.trim() ? (
<div
className={`markdown-body ${styles.markdownBody}`}
dangerouslySetInnerHTML={{ __html: systemPromptHtml }}
/>
) : (
<div className={`${styles.empty} ${styles.emptyCompact}`}></div>
)}
</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 || Boolean(modelsConfigError)}
onClick={() => void saveModelsConfig()}
>
</button>
</div>
<textarea
className={`${styles.textarea} ${modelsConfigError ? styles.textareaInvalid : ""}`}
spellCheck={false}
aria-label="模型配置"
aria-invalid={modelsConfigError ? true : undefined}
value={modelsConfig}
onChange={(e) => setModelsConfig(e.target.value)}
/>
{modelsConfigError ? (
<div className={styles.jsonError} role="alert">
{modelsConfigError}
</div>
) : null}
{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 === "prompts" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "prompts"}
>
{promptEditMode === "list" ? (
<>
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>{promptList.length} · prompts/ </p>
</div>
<div className={styles.panelHeaderActions}>
<button
type="button"
className={styles.secondaryBtn}
disabled={loadingPrompts}
onClick={() => void loadPrompts()}
>
</button>
<button
type="button"
className={styles.primaryBtn}
onClick={openNewPrompt}
>
</button>
</div>
</div>
<div className={styles.list}>
{loadingPrompts ? (
<div className={styles.empty}>...</div>
) : promptList.length === 0 ? (
<div className={styles.empty}></div>
) : (
<div className={styles.promptGrid}>
{promptList.map((p) => (
<button
key={p.name}
type="button"
className={styles.promptCard}
onClick={() => void openPromptEditor(p)}
>
<div className={styles.promptCardTitle}>{p.title}</div>
{p.description ? (
<div className={styles.promptCardDesc}>{p.description}</div>
) : null}
{p.argumentHint ? (
<div className={styles.promptCardHint}>{p.argumentHint}</div>
) : null}
<div className={styles.promptCardName}>{p.name}</div>
</button>
))}
</div>
)}
</div>
</>
) : (
<>
<div className={styles.panelHeader}>
<div className={styles.promptEditorTitle}>
<button
type="button"
className={styles.backBtn}
onClick={() => setPromptEditMode("list")}
aria-label="返回列表"
>
</button>
<input
className={styles.promptFilenameInput}
value={promptFilename}
onChange={(e) => setPromptFilename(e.target.value)}
placeholder="文件名.md"
spellCheck={false}
/>
</div>
<div className={styles.panelHeaderActions}>
<button
type="button"
className={`${styles.secondaryBtn} ${promptPreview ? styles.headerBtnActive : ""}`}
onClick={() => setPromptPreview((v) => !v)}
>
{promptPreview ? "编辑" : "预览"}
</button>
{promptOriginalName ? (
<button
type="button"
className={styles.dangerBtn}
disabled={deletingPrompt}
onClick={() => void deletePrompt()}
>
</button>
) : null}
<button
type="button"
className={styles.primaryBtn}
disabled={savingPrompt}
onClick={() => void savePrompt()}
>
</button>
</div>
</div>
{promptPreview ? (
<div className={styles.markdownPreview}>
{promptContent.trim() ? (
<div
className={`markdown-body ${styles.markdownBody}`}
dangerouslySetInnerHTML={{ __html: renderMarkdown(promptContent) }}
/>
) : (
<div className={`${styles.empty} ${styles.emptyCompact}`}></div>
)}
</div>
) : (
<textarea
className={styles.textarea}
spellCheck={false}
aria-label="命令模板内容"
value={promptContent}
onChange={(e) => setPromptContent(e.target.value)}
placeholder={"在此输入提示词内容...\n\n支持 YAML frontmatter\n---\ndescription: 描述\nargument-hint: <参数说明>\n---"}
/>
)}
{statusText && activePane === "prompts" ? (
<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>
<div className={styles.panelHeaderActions}>
<button
type="button"
className={styles.secondaryBtn}
disabled={restarting}
onClick={() => void restartBackend()}
>
{restarting ? "重启中..." : "重启后端"}
</button>
<button
type="button"
className={styles.primaryBtn}
disabled={savingAvatars}
onClick={() => void saveAvatars()}
>
</button>
</div>
</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 className={styles.field}>
<span className={styles.fieldLabel}></span>
<label className={styles.skillToggle}>
<input
type="checkbox"
checked={showThinkingAndTools}
onChange={toggleShowThinkingAndTools}
/>
<span className={styles.skillToggleUi} aria-hidden />
<span className={styles.skillToggleLabel}>
{showThinkingAndTools ? "已开启" : "已关闭"}
</span>
</label>
<p className={styles.fieldHint}>
AI
</p>
</div>
</div>
{statusText && activePane === "other" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${styles.terminalPanel} ${activePane === "terminal" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "terminal"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
</div>
</div>
{terminalRequested ? (
<Suspense fallback={<div className={styles.status}></div>}>
<WebTerminal active={activePane === "terminal"} platform={terminalPlatform} />
</Suspense>
) : null}
</section>
<section
className={`${styles.pane} ${styles.panel} ${activePane === "env" ? styles.paneActive : ""}`}
role="tabpanel"
hidden={activePane !== "env"}
>
<div className={styles.panelHeader}>
<div>
<h2></h2>
</div>
<button
type="button"
className={styles.secondaryBtn}
disabled={loadingEnv || loadingTools}
onClick={() => { void loadEnvInfo(); void loadToolsInfo(); }}
>
</button>
</div>
<div className={styles.formBody}>
{loadingEnv ? (
<div className={styles.empty}>...</div>
) : !envInfo ? (
<div className={styles.empty}></div>
) : (
<div className={styles.envGrid}>
<div className={styles.envSectionHeader}></div>
{(
[
["操作系统", envInfo.osRelease || envInfo.platform],
["平台 / 架构", envInfo.platform && envInfo.arch ? `${envInfo.platform} / ${envInfo.arch}` : undefined],
["主机名", envInfo.hostname],
["CPU 核心数", envInfo.numCPU !== undefined ? `${envInfo.numCPU}` : undefined],
["系统总内存", envInfo.totalMemMb ? `${envInfo.totalMemMb} MB` : undefined],
["可用内存", envInfo.freeMemMb !== undefined && envInfo.totalMemMb
? `${envInfo.freeMemMb} MB${envInfo.memUsagePct ? `(已用 ${envInfo.memUsagePct}` : ""}`
: undefined],
] 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 className={styles.envSectionHeader}></div>
{(
[
["Go 版本", envInfo.goVersion || envInfo.nodeVersion],
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
["运行时长", envInfo.uptime !== undefined ? formatUptime(envInfo.uptime) : undefined],
["Goroutine 数", envInfo.goroutines !== undefined ? String(envInfo.goroutines) : undefined],
["Go 堆内存", envInfo.goHeapMb !== undefined ? `${envInfo.goHeapMb} MB` : undefined],
["可执行文件", envInfo.execPath],
] 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 className={styles.envSectionHeader}></div>
{(
[
["监听端口", envInfo.port !== undefined ? String(envInfo.port) : undefined],
["Agent 目录", envInfo.agentDir],
["数据目录", envInfo.dataDir],
["项目根目录", envInfo.repoRoot],
["工作目录", envInfo.cwd],
["SproutClaw 命令", envInfo.sproutclawCmd || envInfo.piCmd],
] 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 className={styles.envSectionHeader}>
Agent {loadingTools ? <span className={styles.envSectionLoading}> · ...</span> : null}
</div>
{loadingTools && !toolsInfo ? (
<div className={`${styles.envRow} ${styles.envLoadingRow}`}> Agent ...</div>
) : Array.isArray(toolsInfo?.agentTools) ? (
toolsInfo!.agentTools.map((tool: AgentToolInfo) => (
<div key={tool.name} className={styles.envRow}>
<span className={styles.envLabel}>{tool.name}</span>
{tool.found ? (
<span className={styles.envToolFound}>
<code className={styles.envValue}>{tool.version || "已安装"}</code>
{tool.path ? <span className={styles.envToolPath}>{tool.path}</span> : null}
</span>
) : (
<span className={styles.envToolMissing}></span>
)}
</div>
))
) : null}
<div className={styles.envSectionHeader}>
{loadingTools ? <span className={styles.envSectionLoading}> · ...</span> : null}
</div>
{loadingTools && !toolsInfo ? (
<div className={`${styles.envRow} ${styles.envLoadingRow}`}>...</div>
) : Array.isArray(toolsInfo?.runtimes) ? (
toolsInfo!.runtimes.map((rt: RuntimeInfo) => (
<div key={rt.name} className={styles.envRow}>
<span className={styles.envLabel}>{rt.name}</span>
{rt.found ? (
<span className={styles.envToolFound}>
<code className={styles.envValue}>{rt.version || "已安装"}</code>
{rt.path ? <span className={styles.envToolPath}>{rt.path}</span> : null}
</span>
) : (
<span className={styles.envToolMissing}></span>
)}
</div>
))
) : null}
</div>
)}
</div>
{statusText && activePane === "env" ? (
<div className={`${styles.status} ${statusClass}`}>{statusText}</div>
) : null}
</section>
</div>
</div>
</div>
</main>
</div>
);
}
export default SettingsPage;