mirror of
https://github.com/luckyyzh/pi-agent-integrated.git
synced 2026-10-03 02:59:35 +00:00
feat: webUI MCP 可视化配置面板 + README 更新
This commit is contained in:
@@ -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.
|
||||
|
||||
@@ -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 });
|
||||
}
|
||||
}
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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",
|
||||
|
||||
@@ -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": "已复制",
|
||||
|
||||
Reference in New Issue
Block a user