first commit
This commit is contained in:
@@ -181,6 +181,55 @@
|
||||
word-break: break-all;
|
||||
}
|
||||
|
||||
.panelHeaderActions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.headerBtnActive {
|
||||
border-color: #2563eb;
|
||||
color: #2563eb;
|
||||
background: #eff6ff;
|
||||
}
|
||||
|
||||
.markdownPreview {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
overflow: auto;
|
||||
background: #fff;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
max-width: none;
|
||||
min-height: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 24px 28px;
|
||||
}
|
||||
|
||||
.markdownBody :global(pre.assistant-pre) {
|
||||
margin: 0.5em 0 0.6em;
|
||||
padding: 0;
|
||||
background: transparent;
|
||||
border: 1px solid #d0d7de;
|
||||
border-radius: 6px;
|
||||
overflow-x: auto;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.markdownBody :global(pre.assistant-pre code.hljs) {
|
||||
display: block;
|
||||
padding: 16px;
|
||||
background: #f6f8fa;
|
||||
border: none;
|
||||
color: inherit;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
word-break: normal;
|
||||
tab-size: 2;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.linkBtn {
|
||||
@@ -256,6 +305,20 @@
|
||||
box-shadow: inset 0 0 0 2px rgba(37, 99, 235, 0.18);
|
||||
}
|
||||
|
||||
.textareaInvalid:focus {
|
||||
box-shadow: inset 0 0 0 2px rgba(220, 38, 38, 0.35);
|
||||
}
|
||||
|
||||
.jsonError {
|
||||
flex: 0 0 auto;
|
||||
padding: 8px 14px;
|
||||
color: #b91c1c;
|
||||
background: #fef2f2;
|
||||
border-top: 1px solid #fecaca;
|
||||
font-size: 13px;
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.formBody {
|
||||
flex: 1 1 auto;
|
||||
min-height: 0;
|
||||
@@ -453,17 +516,23 @@
|
||||
.envValue {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-family: ui-monospace, 'Cascadia Code', 'Fira Mono', monospace;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 12px;
|
||||
color: #111827;
|
||||
word-break: break-all;
|
||||
background: none;
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
.terminalPanel {
|
||||
min-height: 420px;
|
||||
}
|
||||
|
||||
.terminalPanel .panelHeader {
|
||||
border-bottom-color: #30363d;
|
||||
}
|
||||
|
||||
.terminalPanel .panelHeader p {
|
||||
color: #8b949e;
|
||||
}
|
||||
|
||||
.extensionHeader {
|
||||
@@ -807,51 +876,170 @@
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.pageHeader {
|
||||
align-items: flex-start;
|
||||
align-items: center;
|
||||
padding-top: max(12px, env(safe-area-inset-top));
|
||||
padding-bottom: 12px;
|
||||
padding-left: max(12px, env(safe-area-inset-left));
|
||||
padding-right: max(12px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.pageTitle h1 {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.layout {
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.sidebar {
|
||||
width: 100%;
|
||||
flex: 0 0 auto;
|
||||
flex-direction: row;
|
||||
flex-wrap: nowrap;
|
||||
align-items: stretch;
|
||||
gap: 8px;
|
||||
padding: 10px max(12px, env(safe-area-inset-left)) 10px max(12px, env(safe-area-inset-right));
|
||||
gap: 6px;
|
||||
padding: 8px max(10px, env(safe-area-inset-left)) 8px max(10px, env(safe-area-inset-right));
|
||||
border-right: none;
|
||||
border-bottom: 1px solid #e5e7eb;
|
||||
overflow-x: auto;
|
||||
overflow-y: hidden;
|
||||
-webkit-overflow-scrolling: touch;
|
||||
scroll-snap-type: x proximity;
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
.sidebar::-webkit-scrollbar {
|
||||
height: 0;
|
||||
width: 0;
|
||||
display: none;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
flex: 1 1 0;
|
||||
min-width: 0;
|
||||
flex: 0 0 auto;
|
||||
width: auto;
|
||||
min-width: auto;
|
||||
justify-content: center;
|
||||
text-align: center;
|
||||
padding: 12px 8px;
|
||||
white-space: nowrap;
|
||||
padding: 9px 14px;
|
||||
font-size: 13px;
|
||||
scroll-snap-align: start;
|
||||
}
|
||||
|
||||
.panes {
|
||||
flex: 1;
|
||||
min-height: 0;
|
||||
padding: 10px max(10px, env(safe-area-inset-left)) max(12px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||
padding: 10px max(10px, env(safe-area-inset-left)) max(10px, env(safe-area-inset-bottom)) max(10px, env(safe-area-inset-right));
|
||||
}
|
||||
|
||||
.panel {
|
||||
border-radius: 10px;
|
||||
}
|
||||
|
||||
.panelHeader {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
gap: 10px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.panelHeaderActions {
|
||||
width: 100%;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.panelHeader h2 {
|
||||
font-size: 15px;
|
||||
}
|
||||
|
||||
.panelHeader p {
|
||||
font-size: 11px;
|
||||
line-height: 1.45;
|
||||
word-break: break-word;
|
||||
}
|
||||
|
||||
.markdownBody {
|
||||
padding: 16px 14px;
|
||||
}
|
||||
|
||||
.envRow {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: 4px;
|
||||
padding: 10px 12px;
|
||||
}
|
||||
|
||||
.envLabel {
|
||||
width: auto;
|
||||
}
|
||||
|
||||
.mcpServerHead {
|
||||
flex-direction: column;
|
||||
align-items: stretch;
|
||||
}
|
||||
|
||||
.mcpServerBadges {
|
||||
justify-content: flex-start;
|
||||
}
|
||||
|
||||
.titleRow {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.skillActions {
|
||||
flex-wrap: wrap;
|
||||
justify-content: flex-end;
|
||||
}
|
||||
|
||||
.extensionTitleRow {
|
||||
flex-wrap: wrap;
|
||||
align-items: flex-start;
|
||||
gap: 6px;
|
||||
}
|
||||
|
||||
.terminalPanel {
|
||||
min-height: min(420px, calc(var(--app-height) - 180px));
|
||||
}
|
||||
|
||||
.textarea {
|
||||
font-size: 12px;
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.formBody {
|
||||
padding: 12px;
|
||||
}
|
||||
|
||||
.list {
|
||||
padding: 6px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (max-width: 480px) {
|
||||
.extensionGroupList {
|
||||
grid-template-columns: 1fr;
|
||||
}
|
||||
|
||||
.navBtn {
|
||||
padding: 8px 12px;
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.primaryBtn,
|
||||
.secondaryBtn,
|
||||
.linkBtn {
|
||||
height: 34px;
|
||||
padding: 0 14px;
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.panelHeaderActions {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.panelHeaderActions .primaryBtn,
|
||||
.panelHeaderActions .secondaryBtn {
|
||||
flex: 1 1 auto;
|
||||
min-width: 88px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user