feat: update chat, settings, prompts and system info modules
This commit is contained in:
24
frontend/src/api/prompts.ts
Normal file
24
frontend/src/api/prompts.ts
Normal 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 });
|
||||
}
|
||||
@@ -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");
|
||||
}
|
||||
|
||||
@@ -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}>
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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 {
|
||||
|
||||
Reference in New Issue
Block a user