feat: webUI MCP 可视化配置面板 + README 更新

This commit is contained in:
luckyyzh
2026-08-02 22:27:50 +08:00
parent a70798e874
commit 5378d48095
6 changed files with 843 additions and 0 deletions
+4
View File
@@ -127,6 +127,8 @@ data/workspaces/default/ 默认工作目录
Playwright 不下载独立 Chromium。首次 `setup` 只缓存 MCP 的 Node.js 包;浏览器执行使用系统 Edge。
MCP 服务器可通过 Web UI 左下角的 MCP 按钮可视化配置(写入 `data/agent/mcp.json`):支持 stdio(命令 + 参数)与 HTTP(URL + 请求头 + OAuth/Bearer)两种传输、环境变量键值编辑、工作目录、生命周期与超时设置,另保留原始 JSON 编辑兜底。保存后重启 pi(或 /reload)生效。
#### 记忆模式
默认设置 `PI_MEMORY_NO_SEARCH=1` 和 `PI_MEMORY_QMD_UPDATE=off`。这不会削弱 Markdown 记忆、每日日志、scratchpad 或恢复记录,但会跳过 qmd 探测、安装提示和 `memory_search` 工具,因此全新安装不会自动下载 qmd 的本地模型。集成补丁会在 `setup` 以及每次启动前自动检查并重放,受管插件重装后无需手工修改。
@@ -320,6 +322,8 @@ Versions are pinned in `config/settings.default.json` and `config/mcp.default.js
Playwright never downloads a standalone Chromium in this project. Setup caches only its Node package; browser execution uses system Edge.
MCP servers can be configured visually from the MCP button in the lower-left Web UI (writes `data/agent/mcp.json`): stdio (command + args) or HTTP (URL + headers + OAuth/Bearer) transport, environment-variable row editing, working directory, lifecycle and timeout options, plus raw JSON editing as a fallback. Changes take effect after restarting pi (or /reload).
#### Memory modes
The managed launcher defaults to `PI_MEMORY_NO_SEARCH=1` and `PI_MEMORY_QMD_UPDATE=off`. Markdown memory, daily logs, scratchpad, and recovery stay available, while qmd detection, its installation notice, and the `memory_search` tool are skipped. A deterministic integration patch is checked during setup and before every launch, so reinstalling managed packages needs no manual repair.
+58
View File
@@ -0,0 +1,58 @@
import { NextResponse } from "next/server";
import { readFileSync, writeFileSync, existsSync, mkdirSync } from "node:fs";
import { join, dirname } from "node:path";
import { getAgentDir } from "@earendil-works/pi-coding-agent";
export const dynamic = "force-dynamic";
function getMcpPath(): string {
return join(getAgentDir(), "mcp.json");
}
interface McpConfigFile {
mcpServers: Record<string, Record<string, unknown>>;
settings?: Record<string, unknown>;
imports?: string[];
}
function readMcpJson(): McpConfigFile {
const path = getMcpPath();
if (!existsSync(path)) return { mcpServers: {} };
try {
const parsed = JSON.parse(readFileSync(path, "utf8")) as McpConfigFile;
if (!parsed || typeof parsed !== "object" || typeof parsed.mcpServers !== "object") {
return { mcpServers: {} };
}
return parsed;
} catch {
return { mcpServers: {} };
}
}
function writeMcpJson(data: McpConfigFile): void {
const path = getMcpPath();
const dir = dirname(path);
if (!existsSync(dir)) mkdirSync(dir, { recursive: true });
writeFileSync(path, JSON.stringify(data, null, 2) + "\n", "utf8");
}
export function GET() {
return NextResponse.json({ config: readMcpJson(), path: getMcpPath() });
}
export async function PUT(req: Request) {
try {
const body = (await req.json()) as McpConfigFile;
if (!body || typeof body !== "object" || typeof body.mcpServers !== "object" || body.mcpServers === null) {
return NextResponse.json({ error: "mcpServers must be an object" }, { status: 400 });
}
// 只保留已知顶层字段,避免写入无关键
const out: McpConfigFile = { mcpServers: body.mcpServers };
if (body.settings && typeof body.settings === "object") out.settings = body.settings;
if (Array.isArray(body.imports)) out.imports = body.imports;
writeMcpJson(out);
return NextResponse.json({ success: true });
} catch (error) {
return NextResponse.json({ error: String(error) }, { status: 500 });
}
}
+14
View File
@@ -8,6 +8,7 @@ import { ChatWindow } from "./ChatWindow";
import { FileViewer } from "./FileViewer";
import { TabBar, type Tab } from "./TabBar";
import { ModelsConfig } from "./ModelsConfig";
import { McpConfig } from "./McpConfig";
import { SkillsConfig } from "./SkillsConfig";
import { PluginsConfig } from "./PluginsConfig";
import { ProjectTrustDialog } from "./ProjectTrustDialog";
@@ -53,6 +54,7 @@ export function AppShell() {
const [explorerRefreshKey, setExplorerRefreshKey] = useState(0);
const [modelsConfigOpen, setModelsConfigOpen] = useState(false);
const [modelsRefreshKey, setModelsRefreshKey] = useState(0);
const [mcpConfigOpen, setMcpConfigOpen] = useState(false);
const [skillsConfigOpen, setSkillsConfigOpen] = useState(false);
const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false);
const [projectTrust, setProjectTrust] = useState<ProjectTrustStatus | null>(null);
@@ -578,6 +580,17 @@ export function AppShell() {
</svg>
),
},
{
label: translate("common.mcp"),
onClick: () => setMcpConfigOpen(true),
disabled: false,
icon: (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
</svg>
),
},
{
label: translate("common.skills"),
onClick: () => setSkillsConfigOpen(true),
@@ -1514,6 +1527,7 @@ export function AppShell() {
</svg>
</button>
{modelsConfigOpen && <ModelsConfig onClose={() => { setModelsConfigOpen(false); setModelsRefreshKey((k) => k + 1); }} />}
{mcpConfigOpen && <McpConfig onClose={() => setMcpConfigOpen(false)} />}
{projectTrustDialogOpen && projectTrustCwd && (
<ProjectTrustDialog
cwd={projectTrustCwd}
+723
View File
@@ -0,0 +1,723 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useIsMobile } from "@/hooks/useIsMobile";
import { useI18n } from "@/hooks/useI18n";
interface McpServerEntry {
command?: string;
args?: string[];
socket?: string;
env?: Record<string, string>;
cwd?: string;
url?: string;
headers?: Record<string, string>;
auth?: "oauth" | "bearer" | false;
bearerToken?: string;
bearerTokenEnv?: string;
lifecycle?: "keep-alive" | "lazy" | "lazy-keep-alive" | "eager";
idleTimeout?: number;
requestTimeoutMs?: number;
exposeResources?: boolean;
directTools?: boolean | string[];
includeTools?: string[];
excludeTools?: string[];
debug?: boolean;
trace?: boolean;
disabled?: boolean;
[key: string]: unknown;
}
interface McpConfigFile {
mcpServers: Record<string, McpServerEntry>;
settings?: Record<string, unknown>;
imports?: string[];
}
type TransportKind = "stdio" | "http";
function entrySummary(entry: McpServerEntry): string {
if (entry.url) {
try {
return new URL(entry.url).host || entry.url;
} catch {
return entry.url;
}
}
if (entry.socket) return entry.socket;
const cmd = entry.command ?? "";
const args = entry.args?.length ? ` ${entry.args.join(" ")}` : "";
return `${cmd}${args}`;
}
function transportOf(entry: McpServerEntry): TransportKind {
return entry.url ? "http" : "stdio";
}
function envToRows(env?: Record<string, string>): Array<{ key: string; value: string }> {
return Object.entries(env ?? {}).map(([key, value]) => ({ key, value }));
}
function rowsToEnv(rows: Array<{ key: string; value: string }>): Record<string, string> {
const env: Record<string, string> = {};
for (const row of rows) {
const key = row.key.trim();
if (key) env[key] = row.value;
}
return env;
}
// ── Form helpers ─────────────────────────────────────────────────────────────
const inputStyle: React.CSSProperties = {
padding: "6px 9px",
background: "var(--bg-panel)",
border: "1px solid var(--border)",
borderRadius: 5,
color: "var(--text)",
fontSize: 12,
outline: "none",
width: "100%",
boxSizing: "border-box",
};
function Field({ label, children }: { label: string; children: React.ReactNode }) {
return (
<div style={{ display: "flex", flexDirection: "column", gap: 4 }}>
<label style={{ fontSize: 11, color: "var(--text-muted)", fontWeight: 500 }}>{label}</label>
{children}
</div>
);
}
function TextInput({ value, onChange, placeholder, mono }: { value: string; onChange: (v: string) => void; placeholder?: string; mono?: boolean }) {
return (
<input
value={value}
onChange={(e) => onChange(e.target.value)}
placeholder={placeholder}
style={{ ...inputStyle, fontFamily: mono ? "var(--font-mono)" : "inherit" }}
/>
);
}
function TextArea({
value,
onChange,
rows,
placeholder,
mono,
}: {
value: string;
onChange: (v: string) => void;
rows: number;
placeholder?: string;
mono?: boolean;
}) {
return (
<textarea
value={value}
onChange={(e) => onChange(e.target.value)}
rows={rows}
placeholder={placeholder}
spellCheck={false}
style={{
...inputStyle,
resize: "vertical",
lineHeight: 1.5,
fontFamily: mono ? "var(--font-mono)" : "inherit",
}}
/>
);
}
function NumInput({ value, onChange, placeholder }: { value: string; onChange: (v: string) => void; placeholder?: string }) {
return <input type="number" value={value} onChange={(e) => onChange(e.target.value)} placeholder={placeholder} style={inputStyle} />;
}
function Select({ value, onChange, options }: { value: string; onChange: (v: string) => void; options: readonly string[] }) {
const { t } = useI18n();
return (
<select value={value} onChange={(e) => onChange(e.target.value)} style={{ ...inputStyle, color: value ? "var(--text)" : "var(--text-dim)" }}>
<option value="">— {t("i18n.default")} —</option>
{options.map((o) => (
<option key={o} value={o}>
{o}
</option>
))}
</select>
);
}
function buttonStyle(disabled?: boolean, danger?: boolean): React.CSSProperties {
return {
padding: "6px 12px",
background: danger ? "rgba(239,68,68,0.08)" : "none",
border: "1px solid var(--border)",
borderRadius: 6,
color: danger ? "#ef4444" : "var(--text-muted)",
cursor: disabled ? "not-allowed" : "pointer",
fontSize: 12,
opacity: disabled ? 0.5 : 1,
};
}
// ── Env row editor ───────────────────────────────────────────────────────────
function EnvEditor({ rows, onChange }: { rows: Array<{ key: string; value: string }>; onChange: (rows: Array<{ key: string; value: string }>) => void }) {
const { t } = useI18n();
const update = (index: number, patch: Partial<{ key: string; value: string }>) => {
onChange(rows.map((row, i) => (i === index ? { ...row, ...patch } : row)));
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
{rows.map((row, i) => (
<div key={i} style={{ display: "flex", gap: 6 }}>
<input
value={row.key}
onChange={(e) => update(i, { key: e.target.value })}
placeholder="NAME"
spellCheck={false}
style={{ ...inputStyle, width: "38%", fontFamily: "var(--font-mono)" }}
/>
<input
value={row.value}
onChange={(e) => update(i, { value: e.target.value })}
placeholder="value"
spellCheck={false}
style={{ ...inputStyle, flex: 1, fontFamily: "var(--font-mono)" }}
/>
<button
type="button"
onClick={() => onChange(rows.filter((_, j) => j !== i))}
title={t("i18n.remove")}
style={{ ...buttonStyle(false, true), padding: "0 9px", flexShrink: 0 }}
>
✕
</button>
</div>
))}
<button type="button" onClick={() => onChange([...rows, { key: "", value: "" }])} style={{ ...buttonStyle(false), alignSelf: "flex-start" }}>
+ {t("mcp.addEnv")}
</button>
</div>
);
}
// ── Server detail form ───────────────────────────────────────────────────────
function ServerDetail({
name,
entry,
onRename,
onChange,
onDelete,
}: {
name: string;
entry: McpServerEntry;
onRename: (newName: string) => void;
onChange: (entry: McpServerEntry) => void;
onDelete: () => void;
}) {
const { t } = useI18n();
const transport = transportOf(entry);
const [rawJson, setRawJson] = useState(() => JSON.stringify(entry, null, 2));
const [jsonError, setJsonError] = useState<string | null>(null);
const [advancedOpen, setAdvancedOpen] = useState(false);
const [headersDraft, setHeadersDraft] = useState(() => JSON.stringify(entry.headers ?? {}, null, 2));
const [headersInvalid, setHeadersInvalid] = useState(false);
const patch = (p: Partial<McpServerEntry>) => onChange({ ...entry, ...p });
const num = (v: string): number | undefined => (v.trim() === "" ? undefined : Number(v));
const applyJson = () => {
try {
const parsed = JSON.parse(rawJson) as McpServerEntry;
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) throw new Error("object expected");
setJsonError(null);
onChange(parsed);
setAdvancedOpen(false);
} catch (e) {
setJsonError(e instanceof Error ? e.message : String(e));
}
};
const setTransport = (kind: TransportKind) => {
if (kind === transport) return;
if (kind === "stdio") {
const rest: McpServerEntry = { ...entry };
delete rest.url;
delete rest.headers;
delete rest.auth;
delete rest.bearerToken;
delete rest.bearerTokenEnv;
onChange(rest);
} else {
onChange({ ...entry, url: entry.url ?? "https://example.com/mcp" });
}
};
return (
<div style={{ display: "flex", flexDirection: "column", gap: 18, maxWidth: 680 }}>
{/* Header row: name + enable + delete */}
<div style={{ display: "flex", alignItems: "center", gap: 8, flexWrap: "wrap" }}>
<Field label={t("mcp.serverName")}>
<div style={{ display: "flex", gap: 6 }}>
<input
value={name}
onChange={(e) => onRename(e.target.value)}
spellCheck={false}
style={{ ...inputStyle, width: 200, fontFamily: "var(--font-mono)", fontWeight: 600 }}
/>
<button type="button" onClick={onDelete} style={{ ...buttonStyle(false, true), flexShrink: 0 }}>
{t("i18n.remove")}
</button>
</div>
</Field>
<div style={{ marginLeft: "auto", display: "flex", alignItems: "center", gap: 8 }}>
<span style={{ fontSize: 12, color: "var(--text-dim)" }}>{t("mcp.enabled")}</span>
<input
type="checkbox"
checked={!entry.disabled}
onChange={(e) => patch({ disabled: !e.target.checked })}
style={{ width: 14, height: 14, accentColor: "var(--accent)", cursor: "pointer" }}
/>
</div>
</div>
{/* Transport */}
<Field label={t("mcp.transport")}>
<div style={{ display: "inline-flex", border: "1px solid var(--border)", borderRadius: 7, overflow: "hidden", alignSelf: "flex-start" }}>
{(["stdio", "http"] as const).map((kind) => (
<button
key={kind}
type="button"
onClick={() => setTransport(kind)}
style={{
padding: "5px 14px",
border: "none",
cursor: "pointer",
fontSize: 12,
background: transport === kind ? "var(--accent)" : "transparent",
color: transport === kind ? "#fff" : "var(--text-muted)",
}}
>
{kind.toUpperCase()}
</button>
))}
</div>
</Field>
{transport === "stdio" ? (
<>
<Field label={t("mcp.command")}>
<TextInput value={entry.command ?? ""} onChange={(v) => patch({ command: v || undefined })} placeholder="npx" mono />
</Field>
<Field label={t("mcp.args")}>
<TextArea
value={(entry.args ?? []).join("\n")}
onChange={(v) => patch({ args: v.split("\n").map((s) => s.trim()).filter(Boolean) })}
rows={4}
mono
/>
</Field>
</>
) : (
<>
<Field label={t("mcp.url")}>
<TextInput value={entry.url ?? ""} onChange={(v) => patch({ url: v || undefined })} placeholder="https://…/mcp" mono />
</Field>
<Field label={t("mcp.headers")}>
<TextArea
value={headersDraft}
onChange={(v) => {
setHeadersDraft(v);
try {
const parsed = JSON.parse(v) as Record<string, string>;
setHeadersInvalid(false);
patch({ headers: parsed });
} catch {
setHeadersInvalid(true);
}
}}
rows={3}
mono
/>
{headersInvalid && <div style={{ fontSize: 11, color: "#ef4444" }}>{t("mcp.invalidJson")}</div>}
</Field>
<Field label={t("mcp.auth")}>
<Select
value={entry.auth === undefined ? "" : String(entry.auth)}
onChange={(v) => {
if (v === "false") patch({ auth: false });
else if (v === "") patch({ auth: undefined });
else patch({ auth: v as "oauth" | "bearer" });
}}
options={["oauth", "bearer", "false"]}
/>
</Field>
{entry.auth === "bearer" && (
<Field label={t("mcp.bearerToken")}>
<TextInput value={entry.bearerToken ?? ""} onChange={(v) => patch({ bearerToken: v || undefined })} mono />
</Field>
)}
</>
)}
{/* env */}
<EnvEditor rows={envToRows(entry.env)} onChange={(rows) => patch({ env: rowsToEnv(rows) })} />
{/* Optional common fields */}
<div style={{ display: "grid", gridTemplateColumns: "1fr 1fr", gap: "0 14px" }}>
<Field label={t("mcp.cwd")}>
<TextInput value={entry.cwd ?? ""} onChange={(v) => patch({ cwd: v || undefined })} placeholder="${PI_AGENT_APP_ROOT}" mono />
</Field>
<Field label={t("mcp.lifecycle")}>
<Select value={entry.lifecycle ?? ""} onChange={(v) => patch({ lifecycle: (v || undefined) as McpServerEntry["lifecycle"] })} options={["lazy", "keep-alive", "lazy-keep-alive", "eager"]} />
</Field>
<Field label={t("mcp.idleTimeout")}>
<NumInput value={entry.idleTimeout === undefined ? "" : String(entry.idleTimeout)} onChange={(v) => patch({ idleTimeout: num(v) })} placeholder="5" />
</Field>
<Field label={t("mcp.requestTimeout")}>
<NumInput value={entry.requestTimeoutMs === undefined ? "" : String(entry.requestTimeoutMs)} onChange={(v) => patch({ requestTimeoutMs: num(v) })} placeholder="120000" />
</Field>
</div>
{/* Advanced raw JSON */}
<div style={{ display: "flex", flexDirection: "column", gap: 6 }}>
<button
type="button"
onClick={() => setAdvancedOpen((o) => !o)}
style={{ ...buttonStyle(false), alignSelf: "flex-start" }}
>
{advancedOpen ? "▾ " : "▸ "} {t("mcp.advanced")}
</button>
{advancedOpen && (
<>
<TextArea value={rawJson} onChange={(v) => { setRawJson(v); setJsonError(null); }} rows={12} mono />
{jsonError && <div style={{ fontSize: 11, color: "#ef4444" }}>{t("mcp.invalidJson")}: {jsonError}</div>}
<div>
<button type="button" onClick={applyJson} style={{ ...buttonStyle(false), background: "var(--accent)", color: "#fff", borderColor: "var(--accent)" }}>
{t("mcp.applyJson")}
</button>
</div>
</>
)}
</div>
</div>
);
}
// ── Main panel ───────────────────────────────────────────────────────────────
export function McpConfig({ onClose }: { onClose: () => void }) {
const isMobile = useIsMobile();
const { t } = useI18n();
const [config, setConfig] = useState<McpConfigFile>({ mcpServers: {} });
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string | null>(null);
const [savedOk, setSavedOk] = useState(false);
const [selected, setSelected] = useState<string | null>(null);
const [configPath, setConfigPath] = useState("");
const load = useCallback(async () => {
setLoading(true);
setSaveError(null);
try {
const res = await fetch("/api/mcp");
const data = (await res.json()) as { config?: McpConfigFile; path?: string; error?: string };
if (!res.ok || data.error) throw new Error(data.error ?? `HTTP ${res.status}`);
const next = data.config ?? { mcpServers: {} };
setConfig(next);
setConfigPath(data.path ?? "");
setSelected((current) => (current && next.mcpServers[current] ? current : Object.keys(next.mcpServers)[0] ?? null));
} catch (e) {
setSaveError(e instanceof Error ? e.message : String(e));
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
void load();
}, [load]);
const updateServer = useCallback((name: string, entry: McpServerEntry) => {
setConfig((prev) => ({ ...prev, mcpServers: { ...prev.mcpServers, [name]: entry } }));
}, []);
const renameServer = useCallback((oldName: string, newName: string) => {
const trimmed = newName.trim();
if (!trimmed || trimmed === oldName) return;
setConfig((prev) => {
if (prev.mcpServers[trimmed]) return prev; // 同名已存在
const mcpServers = { ...prev.mcpServers };
mcpServers[trimmed] = mcpServers[oldName];
delete mcpServers[oldName];
return { ...prev, mcpServers };
});
setSelected(trimmed);
}, []);
const deleteServer = useCallback((name: string) => {
setConfig((prev) => {
const mcpServers = { ...prev.mcpServers };
delete mcpServers[name];
return { ...prev, mcpServers };
});
setSelected((current) => {
if (current !== name) return current;
const remaining = Object.keys(config.mcpServers).filter((k) => k !== name);
return remaining[0] ?? null;
});
}, [config.mcpServers]);
const addServer = useCallback(() => {
let finalName = "new-server";
let n = 1;
while (config.mcpServers[finalName]) finalName = `new-server-${n++}`;
setConfig((prev) => ({ ...prev, mcpServers: { ...prev.mcpServers, [finalName]: {} } }));
setSelected(finalName);
}, [config.mcpServers]);
const handleSave = useCallback(async () => {
setSaving(true);
setSaveError(null);
setSavedOk(false);
try {
const res = await fetch("/api/mcp", {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify(config),
});
const d = (await res.json()) as { success?: boolean; error?: string };
if (!res.ok || d.error) setSaveError(d.error ?? `HTTP ${res.status}`);
else {
setSavedOk(true);
setTimeout(() => setSavedOk(false), 2000);
}
} catch (e) {
setSaveError(String(e));
} finally {
setSaving(false);
}
}, [config]);
const names = useMemo(() => Object.entries(config.mcpServers), [config.mcpServers]);
const selectedEntry = selected ? config.mcpServers[selected] : undefined;
return (
<div
style={{
position: "fixed",
inset: 0,
zIndex: 1000,
background: "rgba(0,0,0,0.35)",
display: "flex",
alignItems: "center",
justifyContent: "center",
}}
onClick={(e) => {
if (e.target === e.currentTarget) onClose();
}}
>
<div
style={{
width: isMobile ? "calc(100vw - 16px)" : 860,
maxWidth: "calc(100vw - 16px)",
height: isMobile ? "calc(100dvh - 16px)" : "78vh",
maxHeight: "calc(100dvh - 16px)",
background: "var(--bg)",
border: "1px solid var(--border)",
borderRadius: 10,
display: "flex",
flexDirection: "column",
boxShadow: "0 8px 32px rgba(0,0,0,0.18)",
overflow: "hidden",
}}
>
{/* Header */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", padding: "12px 18px", borderBottom: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ display: "flex", alignItems: "baseline", gap: 10 }}>
<span style={{ fontSize: 15, fontWeight: 700, color: "var(--text)" }}>{t("common.mcp")}</span>
<code style={{ fontSize: 11, color: "var(--text-muted)", fontFamily: "var(--font-mono)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{configPath || "data/agent/mcp.json"}
</code>
</div>
<button onClick={onClose} style={{ background: "none", border: "none", color: "var(--text-muted)", cursor: "pointer", fontSize: 20, lineHeight: 1, padding: "2px 6px" }}>
×
</button>
</div>
{/* Body */}
<div style={{ flex: 1, display: "flex", flexDirection: isMobile ? "column" : "row", overflow: "hidden" }}>
{/* Left: server list */}
<div
style={{
width: isMobile ? "100%" : 230,
maxHeight: isMobile ? "40vh" : undefined,
borderRight: isMobile ? "none" : "1px solid var(--border)",
borderBottom: isMobile ? "1px solid var(--border)" : "none",
display: "flex",
flexDirection: "column",
flexShrink: 0,
background: "var(--bg-panel)",
}}
>
<div style={{ flex: 1, overflowY: "auto", padding: "8px 6px" }}>
{loading ? (
<div style={{ padding: "10px 8px", fontSize: 12, color: "var(--text-muted)" }}>{t("i18n.loading")}</div>
) : names.length === 0 ? (
<div style={{ padding: "10px 8px", fontSize: 11, color: "var(--text-dim)" }}>{t("mcp.noServers")}</div>
) : (
names.map(([sName, sEntry]) => {
const isSelected = selected === sName;
return (
<div
key={sName}
onClick={() => setSelected(sName)}
style={{ display: "flex", alignItems: "center", gap: 7, padding: "8px 8px", borderRadius: 5, cursor: "pointer", background: isSelected ? "var(--bg-selected)" : "none" }}
onMouseEnter={(e) => {
if (!isSelected) e.currentTarget.style.background = "var(--bg-hover)";
}}
onMouseLeave={(e) => {
if (!isSelected) e.currentTarget.style.background = "none";
}}
>
<span
style={{
flexShrink: 0,
width: 7,
height: 7,
borderRadius: "50%",
background: sEntry.disabled ? "var(--text-dim)" : "var(--accent)",
}}
/>
<div style={{ minWidth: 0, flex: 1 }}>
<div
style={{
fontSize: 12,
fontWeight: isSelected ? 600 : 400,
color: "var(--text)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
}}
>
{sName}
</div>
<div
style={{
fontSize: 10,
color: "var(--text-dim)",
fontFamily: "var(--font-mono)",
overflow: "hidden",
textOverflow: "ellipsis",
whiteSpace: "nowrap",
marginTop: 2,
}}
title={entrySummary(sEntry)}
>
{entrySummary(sEntry) || (sEntry.disabled ? t("i18n.disabled") : "—")}
</div>
</div>
</div>
);
})
)}
</div>
{/* Add server */}
<div style={{ borderTop: "1px solid var(--border)", padding: "8px 6px" }}>
<button
type="button"
onClick={addServer}
style={{
display: "flex",
alignItems: "center",
justifyContent: "center",
gap: 5,
width: "100%",
padding: "6px 0",
background: "none",
border: "1px dashed var(--border)",
borderRadius: 5,
color: "var(--text-muted)",
cursor: "pointer",
fontSize: 12,
}}
onMouseEnter={(e) => {
e.currentTarget.style.borderColor = "var(--accent)";
e.currentTarget.style.color = "var(--accent)";
}}
onMouseLeave={(e) => {
e.currentTarget.style.borderColor = "var(--border)";
e.currentTarget.style.color = "var(--text-muted)";
}}
>
+ {t("mcp.addServer")}
</button>
</div>
</div>
{/* Right: detail */}
<div style={{ flex: 1, overflowY: "auto", padding: 20 }}>
{loading ? null : selected && selectedEntry ? (
<ServerDetail
key={selected}
name={selected}
entry={selectedEntry}
onRename={(n) => renameServer(selected, n)}
onChange={(entry) => updateServer(selected, entry)}
onDelete={() => deleteServer(selected)}
/>
) : (
<div style={{ height: "100%", display: "flex", alignItems: "center", justifyContent: "center", color: "var(--text-dim)", fontSize: 13 }}>
{t("mcp.selectServer")}
</div>
)}
</div>
</div>
{/* Footer */}
<div style={{ display: "flex", alignItems: "center", justifyContent: "space-between", gap: 12, padding: "10px 18px", borderTop: "1px solid var(--border)", flexShrink: 0 }}>
<div style={{ minWidth: 0, flex: 1, fontSize: 11, color: "var(--text-dim)", overflow: "hidden", textOverflow: "ellipsis", whiteSpace: "nowrap" }}>
{t("mcp.restartHint")}
</div>
{saveError && <span style={{ fontSize: 12, color: "#f87171", flexShrink: 0 }}>{saveError}</span>}
<button onClick={onClose} style={buttonStyle(false)}>
{t("i18n.close")}
</button>
<button
onClick={handleSave}
disabled={saving || savedOk}
style={{
position: "relative",
padding: "6px 16px",
minWidth: 92,
background: savedOk ? "#16a34a" : saving ? "var(--bg-panel)" : "var(--accent)",
border: "none",
borderRadius: 6,
color: savedOk ? "#fff" : saving ? "var(--text-muted)" : "#fff",
cursor: saving || savedOk ? "default" : "pointer",
fontSize: 13,
fontWeight: 600,
display: "inline-flex",
alignItems: "center",
justifyContent: "center",
gap: 6,
}}
>
{savedOk && (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="3" strokeLinecap="round" strokeLinejoin="round" style={{ flexShrink: 0 }}>
<polyline points="20 6 9 17 4 12" />
</svg>
)}
<span>{savedOk ? t("i18n.saved") : saving ? t("i18n.saving") : t("i18n.save")}</span>
</button>
</div>
</div>
</div>
);
}
+22
View File
@@ -8,6 +8,7 @@ export const enLocale: LocalePlugin = {
"common.ok": "OK",
"common.language": "Language",
"common.models": "Models",
"common.mcp": "MCP",
"common.skills": "Skills",
"common.plugins": "Plugins",
"sidebar.hide": "Hide sidebar",
@@ -236,6 +237,27 @@ export const enLocale: LocalePlugin = {
"chat.commandCopy": "Copy the last assistant message",
"chat.compacted": "Compacted",
"chat.tokensSaved": "{saved} saved",
"mcp.addServer": "Add server",
"mcp.addEnv": "Add env",
"mcp.serverName": "Server name",
"mcp.enabled": "Enabled",
"mcp.transport": "Transport",
"mcp.command": "Command",
"mcp.args": "Arguments (one per line)",
"mcp.url": "URL",
"mcp.headers": "Headers (JSON)",
"mcp.auth": "Auth",
"mcp.bearerToken": "Bearer token",
"mcp.cwd": "Working directory",
"mcp.lifecycle": "Lifecycle",
"mcp.idleTimeout": "Idle timeout (minutes)",
"mcp.requestTimeout": "Request timeout (ms)",
"mcp.advanced": "Advanced (raw JSON)",
"mcp.applyJson": "Apply JSON",
"mcp.invalidJson": "Invalid JSON",
"mcp.noServers": "No MCP servers configured",
"mcp.selectServer": "Select a server to edit",
"mcp.restartHint": "Saved changes apply after restarting pi (or /reload).",
"i18n.close": "Close",
"i18n.copy": "Copy",
"i18n.copied": "Copied",
+22
View File
@@ -8,6 +8,7 @@ export const zhCNLocale: LocalePlugin = {
"common.ok": "确定",
"common.language": "语言",
"common.models": "模型",
"common.mcp": "MCP",
"common.skills": "技能",
"common.plugins": "插件",
"sidebar.hide": "隐藏侧边栏",
@@ -236,6 +237,27 @@ export const zhCNLocale: LocalePlugin = {
"chat.commandCopy": "复制最后一条助手消息",
"chat.compacted": "已压缩",
"chat.tokensSaved": "节省 {saved}",
"mcp.addServer": "添加服务器",
"mcp.addEnv": "添加环境变量",
"mcp.serverName": "服务器名称",
"mcp.enabled": "启用",
"mcp.transport": "传输方式",
"mcp.command": "命令",
"mcp.args": "参数(每行一个)",
"mcp.url": "URL",
"mcp.headers": "请求头(JSON)",
"mcp.auth": "认证",
"mcp.bearerToken": "Bearer token",
"mcp.cwd": "工作目录",
"mcp.lifecycle": "生命周期",
"mcp.idleTimeout": "空闲超时(分钟)",
"mcp.requestTimeout": "请求超时(毫秒)",
"mcp.advanced": "高级(原始 JSON)",
"mcp.applyJson": "应用 JSON",
"mcp.invalidJson": "JSON 无效",
"mcp.noServers": "未配置 MCP 服务器",
"mcp.selectServer": "选择一个服务器进行编辑",
"mcp.restartHint": "保存的更改在重启 pi(或 /reload)后生效。",
"i18n.close": "关闭",
"i18n.copy": "复制",
"i18n.copied": "已复制",