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; 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)) { 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 (
{label}
{items.map((item) => ( {item} ))}
); } 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 (
{extension.name || extension.path || "未命名"}
{toggleable ? ( ) : versionLabel ? (
{versionLabel}
) : null}
{meta ?
{meta}
: null}
{counts.length ? (
{counts.map(([label, count]) => ( {label} {count} ))}
) : null} {hasDetails ? (
查看注册项 {renderNameList("命令", extension.commands)} {renderNameList("工具", extension.tools)} {renderNameList("事件", extension.handlers)} {renderNameList("Flags", extension.flags)} {renderNameList("快捷键", extension.shortcuts)}
) : null} {extension.location ?
{extension.location}
: null}
); } 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 (

{title}

{items.length} 个
{items.length ? (
{items.map((extension) => { const togglePath = extensionTogglePath(extension); return ( { if (!togglePath) return; onToggle(togglePath, enabled); }} /> ); })}
) : (
{emptyText}
)}
); } 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 (
{toolName || "未命名工具"}
{params.length ? {params.length} 参数 : null}
{shortDescription ?
{shortDescription}
: null} {params.length ? (
{params.map((param) => ( {param} {required.has(param) ? "*" : ""} ))}
) : null}
); } export function SettingsPage() { const { updateAvatars } = useAvatars(); const { showThinkingAndTools, toggleShowThinkingAndTools } = useChatContext(); const [activePane, setActivePane] = useState("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([]); const [mcpTools, setMcpTools] = useState([]); const [extensions, setExtensions] = useState([]); 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([]); 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(null); const [togglingMcpServer, setTogglingMcpServer] = useState(null); const [togglingMcpTool, setTogglingMcpTool] = useState(null); const [togglingExtensionPath, setTogglingExtensionPath] = useState(null); const [envInfo, setEnvInfo] = useState(null); const [loadingEnv, setLoadingEnv] = useState(false); const [toolsInfo, setToolsInfo] = useState(null); const [loadingTools, setLoadingTools] = useState(false); const [terminalPlatform, setTerminalPlatform] = useState(""); const [terminalRequested, setTerminalRequested] = useState(false); const sidebarRef = useRef(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('[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 (

设置

返回