first commit

This commit is contained in:
2026-06-14 20:31:10 +08:00
parent c33b143176
commit 1ed3f576fa
51 changed files with 3362 additions and 810 deletions

View File

@@ -1,13 +1,18 @@
import { useCallback, useEffect, useState } from "react";
import { lazy, Suspense, useCallback, useEffect, useMemo, useRef, useState } from "react";
import { Link } from "react-router-dom";
import * as settingsApi from "../api/settings";
import { useAvatars } from "../context/AvatarContext";
import { useBootstrap } from "../context/BootstrapContext";
import type { EnvironmentInfo, ExtensionInfo, McpServerInfo, SettingsPaneId, SkillInfo } from "../types/events";
import type { McpToolInfo } from "../types/events";
import { renderMarkdown } from "../utils/markdown";
import styles from "./SettingsPage.module.css";
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "env"];
const WebTerminal = lazy(() =>
import("../components/terminal/WebTerminal").then((module) => ({ default: module.WebTerminal })),
);
const PANE_IDS: SettingsPaneId[] = ["prompt", "models", "skills", "mcp", "extensions", "other", "terminal", "env"];
function isNpmSkill(skill: SkillInfo): boolean {
if (skill.toggleable === false) return true;
@@ -28,6 +33,41 @@ function formatSkillMeta(skill: SkillInfo): string {
}
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 (
@@ -56,6 +96,13 @@ 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,
@@ -67,10 +114,11 @@ function ExtensionCard({
disabled: boolean;
onToggle: (enabled: boolean) => void;
}) {
const meta =
extension.kind || [extension.scope, extension.source].filter(Boolean).join(" · ");
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],
@@ -89,19 +137,23 @@ function ExtensionCard({
<div className={styles.extensionHeader}>
<div className={styles.extensionTitleRow}>
<div className={styles.itemName}>{extension.name || extension.path || "未命名"}</div>
<label className={styles.skillToggle}>
<input
type="checkbox"
checked={enabled}
disabled={!togglePath || toggling || disabled}
onChange={(e) => {
if (!togglePath) return;
onToggle(e.target.checked);
}}
/>
<span className={styles.skillToggleUi} aria-hidden />
<span className={styles.skillToggleLabel}>{enabled ? "已启用" : "已禁用"}</span>
</label>
{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>
@@ -248,6 +300,7 @@ export function SettingsPage() {
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("");
@@ -269,6 +322,9 @@ export function SettingsPage() {
const [togglingExtensionPath, setTogglingExtensionPath] = useState<string | null>(null);
const [envInfo, setEnvInfo] = useState<EnvironmentInfo | null>(null);
const [loadingEnv, setLoadingEnv] = useState(false);
const [terminalPlatform, setTerminalPlatform] = useState("");
const [terminalRequested, setTerminalRequested] = useState(false);
const sidebarRef = useRef<HTMLElement>(null);
useEffect(() => {
let initial: SettingsPaneId = "prompt";
@@ -290,6 +346,9 @@ export function SettingsPage() {
const showPane = (paneId: SettingsPaneId) => {
setActivePane(paneId);
if (paneId !== "prompt") {
setSystemPromptPreview(false);
}
try {
sessionStorage.setItem(SETTINGS_PANE_KEY, paneId);
} catch {
@@ -338,6 +397,17 @@ export function SettingsPage() {
}
};
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 {
@@ -354,6 +424,17 @@ export function SettingsPage() {
if (activePane === "env" && !envInfo && !loadingEnv) {
void loadEnvInfo();
}
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) => {
@@ -471,6 +552,12 @@ export function SettingsPage() {
};
const saveModelsConfig = async () => {
const validationError = validateModelsConfigJson(modelsConfig);
if (validationError) {
setStatus(validationError, "error");
return;
}
setSavingModels(true);
setStatus("保存中...");
try {
@@ -521,6 +608,11 @@ export function SettingsPage() {
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 : "";
@@ -541,7 +633,7 @@ export function SettingsPage() {
<div className={styles.shell}>
<div className={styles.layout}>
<nav className={styles.sidebar} role="tablist" aria-label="设置分区">
<nav className={styles.sidebar} ref={sidebarRef} role="tablist" aria-label="设置分区">
{(
[
["prompt", "系统提示词"],
@@ -550,6 +642,7 @@ export function SettingsPage() {
["mcp", "MCP工具"],
["extensions", "插件扩展"],
["other", "其他设置"],
["terminal", "终端"],
["env", "环境信息"],
] as const
).map(([id, label]) => (
@@ -577,22 +670,44 @@ export function SettingsPage() {
<h2></h2>
<p>{systemPromptPath}</p>
</div>
<button
type="button"
className={styles.primaryBtn}
disabled={saving}
onClick={() => void saveSystemPrompt()}
>
</button>
<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>
<textarea
className={styles.textarea}
spellCheck={false}
aria-label="系统提示词"
value={systemPrompt}
onChange={(e) => setSystemPrompt(e.target.value)}
/>
{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}
@@ -611,19 +726,25 @@ export function SettingsPage() {
<button
type="button"
className={styles.primaryBtn}
disabled={savingModels}
disabled={savingModels || Boolean(modelsConfigError)}
onClick={() => void saveModelsConfig()}
>
</button>
</div>
<textarea
className={styles.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}
@@ -945,6 +1066,23 @@ export function SettingsPage() {
) : 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"
@@ -953,7 +1091,7 @@ export function SettingsPage() {
<div className={styles.panelHeader}>
<div>
<h2></h2>
<p>Node.js · · </p>
</div>
<button
type="button"
@@ -985,6 +1123,7 @@ export function SettingsPage() {
["空闲内存", envInfo.freeMemMb !== undefined ? envInfo.freeMemMb + " MB" : undefined],
["Node 路径", envInfo.execPath],
["工作目录", envInfo.cwd],
["SproutClaw 根目录", envInfo.repoRoot],
] as [string, string | undefined][]
)
.filter(([, v]) => v !== undefined && v !== "")
@@ -1008,3 +1147,5 @@ export function SettingsPage() {
</div>
);
}
export default SettingsPage;