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

@@ -0,0 +1,24 @@
import { apiGet, apiPost } from "./client";
export interface PromptFile {
name: string;
title: string;
description?: string;
argumentHint?: string;
}
export function fetchPrompts(): Promise<{ prompts: PromptFile[] }> {
return apiGet("/api/prompts");
}
export function fetchPromptFile(name: string): Promise<{ content: string }> {
return apiGet(`/api/prompts/file?name=${encodeURIComponent(name)}`);
}
export function savePromptFile(name: string, content: string): Promise<{ ok?: boolean }> {
return apiPost("/api/prompts/file", { name, content });
}
export function deletePromptFile(name: string): Promise<{ ok?: boolean }> {
return apiPost("/api/prompts/delete", { name });
}

View File

@@ -1,5 +1,5 @@
import { apiGet, apiPost } from "./client";
import type { AvatarSettings, EnvironmentInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
import type { AvatarSettings, EnvironmentInfo, EnvironmentToolsInfo, ExtensionInfo, McpServerInfo, McpToolInfo, SettingsData, SkillInfo } from "../types/events";
export function fetchSettings(): Promise<SettingsData> {
return apiGet("/api/settings");
@@ -55,6 +55,14 @@ export function fetchEnvironment(): Promise<EnvironmentInfo> {
return apiGet("/api/environment");
}
export function fetchEnvironmentTools(): Promise<EnvironmentToolsInfo> {
return apiGet("/api/environment/tools");
}
export function fetchTerminalInfo(): Promise<{ repoRoot?: string; platform?: string }> {
return apiGet("/api/terminal");
}
export function restartBackend(): Promise<{ ok?: boolean }> {
return apiPost("/api/settings/restart");
}

View File

@@ -34,6 +34,7 @@ export function ChatInput() {
const [dragOver, setDragOver] = useState(false);
const [slashCommands, setSlashCommands] = useState<SlashCommand[]>([]);
const [selectedSlashIndex, setSelectedSlashIndex] = useState(0);
const slashMenuWasOpenRef = useRef(false);
const inputRef = useRef<HTMLTextAreaElement>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const touchLike = isTouchLike();
@@ -78,11 +79,15 @@ export function ChatInput() {
const slashMenuOpen = Boolean(slashContext && filteredSlashCommands.length > 0);
useEffect(() => {
setSelectedSlashIndex(0);
}, [value, slashMenuOpen]);
if (slashMenuOpen && !slashMenuWasOpenRef.current) {
setSelectedSlashIndex(0);
}
slashMenuWasOpenRef.current = slashMenuOpen;
}, [slashMenuOpen]);
const canSend = value.trim().length > 0 || pendingImages.length > 0;
// Tab: 只填入命令名,保留空格供用户继续输入参数
const applySlashSelection = (command: SlashCommand) => {
const nextValue = applySlashCompletion(value, command.name);
setValue(nextValue);
@@ -96,6 +101,16 @@ export function ChatInput() {
});
};
// Enter / 点击: 直接执行命令,不需要二次确认
const executeSlashCommand = (command: SlashCommand) => {
setValue("");
if (inputRef.current) {
inputRef.current.style.height = "auto";
inputRef.current.focus();
}
void sendMessage(`/${command.name}`, undefined, { mode: "prompt" });
};
const addImages = async (files: FileList | File[]) => {
const list = Array.from(files);
if (!list.length) return;
@@ -161,7 +176,7 @@ export function ChatInput() {
if (e.key === "Tab") {
e.preventDefault();
const command = filteredSlashCommands[selectedSlashIndex];
if (command) applySlashSelection(command);
if (command) applySlashSelection(command); // Tab = 填入,可继续编辑参数
return;
}
if (e.key === "Escape") {
@@ -176,7 +191,7 @@ export function ChatInput() {
if (e.key === "Enter" && !e.shiftKey) {
e.preventDefault();
const command = filteredSlashCommands[selectedSlashIndex];
if (command) applySlashSelection(command);
if (command) executeSlashCommand(command); // Enter = 立即执行
return;
}
}
@@ -286,7 +301,8 @@ export function ChatInput() {
<SlashCommandMenu
commands={filteredSlashCommands}
selectedIndex={selectedSlashIndex}
onSelect={applySlashSelection}
onSelect={executeSlashCommand}
onHover={setSelectedSlashIndex}
/>
) : null}
<div className={styles.wrapper}>

View File

@@ -1,13 +1,16 @@
.menu {
max-width: 720px;
margin: 0 auto 8px;
display: flex;
flex-direction: column;
gap: 0;
}
.list {
max-height: 240px;
overflow-y: auto;
border: 1px solid #e5e7eb;
border-radius: 12px;
border-radius: 12px 12px 0 0;
background: #fff;
box-shadow: 0 8px 24px rgba(15, 23, 42, 0.08);
}
@@ -55,6 +58,21 @@
white-space: nowrap;
}
.hint {
display: flex;
gap: 12px;
padding: 5px 12px;
border: 1px solid #e5e7eb;
border-top: none;
border-radius: 0 0 12px 12px;
background: #f8fafc;
}
.hint span {
font-size: 11px;
color: #94a3b8;
}
@media (max-width: 768px) {
.item {
grid-template-columns: 1fr;
@@ -64,4 +82,9 @@
.source {
justify-self: start;
}
.hint {
gap: 8px;
flex-wrap: wrap;
}
}

View File

@@ -7,9 +7,10 @@ interface SlashCommandMenuProps {
commands: SlashCommand[];
selectedIndex: number;
onSelect: (command: SlashCommand) => void;
onHover: (index: number) => void;
}
export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCommandMenuProps) {
export function SlashCommandMenu({ commands, selectedIndex, onSelect, onHover }: SlashCommandMenuProps) {
const listRef = useRef<HTMLDivElement>(null);
useEffect(() => {
@@ -31,6 +32,7 @@ export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCom
className={`${styles.item} ${index === selectedIndex ? styles.itemActive : ""}`}
role="option"
aria-selected={index === selectedIndex}
onMouseEnter={() => onHover(index)}
onMouseDown={(e) => {
e.preventDefault();
onSelect(command);
@@ -44,6 +46,12 @@ export function SlashCommandMenu({ commands, selectedIndex, onSelect }: SlashCom
</button>
))}
</div>
<div className={styles.hint}>
<span> </span>
<span>Tab </span>
<span>Enter </span>
<span>Esc </span>
</div>
</div>
);
}

View File

@@ -483,6 +483,7 @@
.envGrid {
display: flex;
flex-direction: column;
flex-shrink: 0;
gap: 0;
border: 1px solid #edf0f4;
border-radius: 8px;
@@ -505,6 +506,21 @@
background: #fafbfc;
}
.envSectionHeader {
padding: 6px 14px 5px;
font-size: 11px;
font-weight: 700;
letter-spacing: 0.06em;
text-transform: uppercase;
color: #9ca3af;
background: #f3f4f6;
border-bottom: 1px solid #edf0f4;
}
.envSectionHeader:not(:first-child) {
border-top: 1px solid #e5e7eb;
}
.envLabel {
flex-shrink: 0;
width: 100px;
@@ -523,6 +539,187 @@
background: none;
}
.envToolFound {
display: flex;
align-items: baseline;
gap: 10px;
flex: 1;
min-width: 0;
}
.envToolPath {
font-size: 11px;
color: #9ca3af;
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.envToolMissing {
font-size: 12px;
color: #9ca3af;
font-style: italic;
}
.envSectionLoading {
font-size: 10px;
font-weight: 400;
color: #b0b8c9;
letter-spacing: 0;
text-transform: none;
}
.envLoadingRow {
color: #b0b8c9;
font-size: 12px;
font-style: italic;
}
/* ===== 命令模板 ===== */
.promptGrid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 8px;
}
@media (max-width: 960px) {
.promptGrid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
}
@media (max-width: 600px) {
.promptGrid {
grid-template-columns: 1fr;
}
}
.promptCard {
display: flex;
flex-direction: column;
gap: 4px;
padding: 12px;
border: 1px solid #e5e7eb;
border-radius: 8px;
background: #fff;
text-align: left;
cursor: pointer;
font: inherit;
transition: border-color 0.12s, box-shadow 0.12s, background 0.12s;
}
.promptCard:hover {
border-color: #2563eb;
background: #f5f8ff;
box-shadow: 0 2px 8px rgba(37, 99, 235, 0.08);
}
.promptCardTitle {
font-size: 13px;
font-weight: 600;
color: #111827;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.promptCardDesc {
font-size: 12px;
color: #6b7280;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.promptCardHint {
font-size: 11px;
color: #9ca3af;
font-family: var(--font-mono);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.promptCardName {
margin-top: 4px;
font-size: 10px;
color: #d1d5db;
font-family: var(--font-mono);
}
.promptEditorTitle {
display: flex;
align-items: center;
gap: 8px;
min-width: 0;
flex: 1;
}
.backBtn {
flex-shrink: 0;
width: 30px;
height: 30px;
border: 1px solid #d1d5db;
border-radius: 7px;
background: #fff;
font-size: 14px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: #374151;
transition: background 0.12s;
}
.backBtn:hover {
background: #f3f4f6;
}
.promptFilenameInput {
flex: 1;
min-width: 0;
height: 30px;
padding: 0 8px;
border: 1px solid #d1d5db;
border-radius: 7px;
background: #fff;
color: #111827;
font-family: var(--font-mono);
font-size: 12px;
outline: none;
}
.promptFilenameInput:focus {
border-color: #2563eb;
box-shadow: 0 0 0 3px rgba(37, 99, 235, 0.12);
}
.dangerBtn {
height: 30px;
padding: 0 12px;
border-radius: 7px;
font-size: 12px;
font-weight: 500;
cursor: pointer;
flex-shrink: 0;
color: #b91c1c;
background: #fff;
border: 1px solid #fca5a5;
transition: background 0.15s, border-color 0.15s;
}
.dangerBtn:hover {
background: #fef2f2;
border-color: #f87171;
}
.dangerBtn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.terminalPanel {
min-height: 420px;
}

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>

View File

@@ -213,22 +213,56 @@ export interface AvatarSettings {
agentAvatarUrl: string;
}
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "terminal" | "env";
export type SettingsPaneId = "prompt" | "models" | "skills" | "mcp" | "extensions" | "other" | "terminal" | "env" | "prompts";
export interface AgentToolInfo {
name: string;
found: boolean;
version?: string;
path?: string;
}
export interface RuntimeInfo {
name: string;
found: boolean;
version?: string;
path?: string;
}
export interface EnvironmentToolsInfo {
agentTools?: AgentToolInfo[];
runtimes?: RuntimeInfo[];
}
export interface EnvironmentInfo {
nodeVersion?: string;
// 系统信息
platform?: string;
arch?: string;
osType?: string;
osRelease?: string;
hostname?: string;
pid?: number;
cwd?: string;
repoRoot?: string;
uptime?: number;
numCPU?: number;
totalMemMb?: number;
freeMemMb?: number;
memUsagePct?: string;
// 进程信息
goVersion?: string;
pid?: number;
uptime?: number;
execPath?: string;
goroutines?: number;
goHeapMb?: number;
// 路径与配置
port?: number;
agentDir?: string;
dataDir?: string;
repoRoot?: string;
cwd?: string;
piCmd?: string;
// Agent 工具
agentTools?: AgentToolInfo[];
// 兼容旧字段
nodeVersion?: string;
osType?: string;
}
export interface RetryState {