- 重构消息列表、思考块与工具调用块样式 - ChatContext 支持 agent 分段与 markdown 渲染优化 - 后端增加 PI 命令配置与 Web 终端改进 - 新增 run-backend.sh 启动脚本,忽略 backend/dist 构建产物
1533 lines
53 KiB
TypeScript
1533 lines
53 KiB
TypeScript
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;
|