feat: update chat, settings, prompts and system info modules

This commit is contained in:
2026-06-16 10:33:54 +08:00
parent b86c29c6a4
commit dd13d21c6a
18 changed files with 1362 additions and 43 deletions

View File

@@ -1,9 +1,11 @@
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 { useBootstrap } from "../context/BootstrapContext";
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
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";
@@ -12,7 +14,17 @@ const WebTerminal = lazy(() =>
import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })),
);
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "terminal", "env"];
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;
@@ -316,12 +328,27 @@ export function SettingsPage() {
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);
@@ -420,9 +447,103 @@ export function SettingsPage() {
}
};
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" && !envInfo && !loadingEnv) {
void loadEnvInfo();
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();
@@ -573,6 +694,27 @@ export function SettingsPage() {
}
};
const restartBackend = async () => {
setRestarting(true);
setStatus("正在重启后端...");
try {
await settingsApi.restartBackend();
} catch {
// 后端重启时连接断开属正常现象,忽略错误
}
// 轮询直到后端恢复
const poll = async () => {
try {
await settingsApi.fetchSettings();
setStatus("后端已重启,正在刷新...", "ok");
setTimeout(() => window.location.reload(), 800);
} catch {
setTimeout(poll, 1000);
}
};
setTimeout(poll, 1500);
};
const saveAvatars = async () => {
setSavingAvatars(true);
setStatus("保存中...");
@@ -641,6 +783,7 @@ export function SettingsPage() {
["skills", "Skills"],
["mcp", "MCP工具"],
["extensions", "插件扩展"],
["prompts", "命令模板"],
["other", "其他设置"],
["terminal", "终端"],
["env", "环境信息"],
@@ -993,6 +1136,141 @@ export function SettingsPage() {
) : 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"
@@ -1003,14 +1281,24 @@ export function SettingsPage() {
<h2></h2>
<p>使</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={savingAvatars}
onClick={() => void saveAvatars()}
>
</button>
<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}>
@@ -1096,8 +1384,8 @@ export function SettingsPage() {
<button
type="button"
className={styles.secondaryBtn}
disabled={loadingEnv}
onClick={() => void loadEnvInfo()}
disabled={loadingEnv || loadingTools}
onClick={() => { void loadEnvInfo(); void loadToolsInfo(); }}
>
</button>
@@ -1109,21 +1397,17 @@ export function SettingsPage() {
<div className={styles.empty}></div>
) : (
<div className={styles.envGrid}>
<div className={styles.envSectionHeader}></div>
{(
[
["Node.js 版本", envInfo.nodeVersion],
["平台", envInfo.platform],
["架构", envInfo.arch],
["系统类型", envInfo.osType],
["系统版本", envInfo.osRelease],
["操作系统", envInfo.osRelease || envInfo.platform],
["平台 / 架构", envInfo.platform && envInfo.arch ? `${envInfo.platform} / ${envInfo.arch}` : undefined],
["主机名", envInfo.hostname],
["进程 PID", envInfo.pid !== undefined ? String(envInfo.pid) : undefined],
["运行时长", envInfo.uptime !== undefined ? envInfo.uptime + " 秒" : undefined],
["内存", envInfo.totalMemMb !== undefined ? envInfo.totalMemMb + " MB" : undefined],
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
["Node 路径", envInfo.execPath],
["工作目录", envInfo.cwd],
["SproutClaw 根目录", envInfo.repoRoot],
["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 !== "")
@@ -1133,6 +1417,82 @@ export function SettingsPage() {
<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],
["Pi 命令", 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>