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。
|
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` 以及每次启动前自动检查并重放,受管插件重装后无需手工修改。
|
默认设置 `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.
|
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
|
#### 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.
|
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 { FileViewer } from "./FileViewer";
|
||||||
import { TabBar, type Tab } from "./TabBar";
|
import { TabBar, type Tab } from "./TabBar";
|
||||||
import { ModelsConfig } from "./ModelsConfig";
|
import { ModelsConfig } from "./ModelsConfig";
|
||||||
|
import { McpConfig } from "./McpConfig";
|
||||||
import { SkillsConfig } from "./SkillsConfig";
|
import { SkillsConfig } from "./SkillsConfig";
|
||||||
import { PluginsConfig } from "./PluginsConfig";
|
import { PluginsConfig } from "./PluginsConfig";
|
||||||
import { ProjectTrustDialog } from "./ProjectTrustDialog";
|
import { ProjectTrustDialog } from "./ProjectTrustDialog";
|
||||||
@@ -53,6 +54,7 @@ export function AppShell() {
|
|||||||
const [explorerRefreshKey, setExplorerRefreshKey] = useState(0);
|
const [explorerRefreshKey, setExplorerRefreshKey] = useState(0);
|
||||||
const [modelsConfigOpen, setModelsConfigOpen] = useState(false);
|
const [modelsConfigOpen, setModelsConfigOpen] = useState(false);
|
||||||
const [modelsRefreshKey, setModelsRefreshKey] = useState(0);
|
const [modelsRefreshKey, setModelsRefreshKey] = useState(0);
|
||||||
|
const [mcpConfigOpen, setMcpConfigOpen] = useState(false);
|
||||||
const [skillsConfigOpen, setSkillsConfigOpen] = useState(false);
|
const [skillsConfigOpen, setSkillsConfigOpen] = useState(false);
|
||||||
const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false);
|
const [pluginsConfigOpen, setPluginsConfigOpen] = useState(false);
|
||||||
const [projectTrust, setProjectTrust] = useState<ProjectTrustStatus | null>(null);
|
const [projectTrust, setProjectTrust] = useState<ProjectTrustStatus | null>(null);
|
||||||
@@ -578,6 +580,17 @@ export function AppShell() {
|
|||||||
</svg>
|
</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"),
|
label: translate("common.skills"),
|
||||||
onClick: () => setSkillsConfigOpen(true),
|
onClick: () => setSkillsConfigOpen(true),
|
||||||
@@ -1514,6 +1527,7 @@ export function AppShell() {
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
{modelsConfigOpen && <ModelsConfig onClose={() => { setModelsConfigOpen(false); setModelsRefreshKey((k) => k + 1); }} />}
|
{modelsConfigOpen && <ModelsConfig onClose={() => { setModelsConfigOpen(false); setModelsRefreshKey((k) => k + 1); }} />}
|
||||||
|
{mcpConfigOpen && <McpConfig onClose={() => setMcpConfigOpen(false)} />}
|
||||||
{projectTrustDialogOpen && projectTrustCwd && (
|
{projectTrustDialogOpen && projectTrustCwd && (
|
||||||
<ProjectTrustDialog
|
<ProjectTrustDialog
|
||||||
cwd={projectTrustCwd}
|
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.ok": "OK",
|
||||||
"common.language": "Language",
|
"common.language": "Language",
|
||||||
"common.models": "Models",
|
"common.models": "Models",
|
||||||
|
"common.mcp": "MCP",
|
||||||
"common.skills": "Skills",
|
"common.skills": "Skills",
|
||||||
"common.plugins": "Plugins",
|
"common.plugins": "Plugins",
|
||||||
"sidebar.hide": "Hide sidebar",
|
"sidebar.hide": "Hide sidebar",
|
||||||
@@ -236,6 +237,27 @@ export const enLocale: LocalePlugin = {
|
|||||||
"chat.commandCopy": "Copy the last assistant message",
|
"chat.commandCopy": "Copy the last assistant message",
|
||||||
"chat.compacted": "Compacted",
|
"chat.compacted": "Compacted",
|
||||||
"chat.tokensSaved": "{saved} saved",
|
"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.close": "Close",
|
||||||
"i18n.copy": "Copy",
|
"i18n.copy": "Copy",
|
||||||
"i18n.copied": "Copied",
|
"i18n.copied": "Copied",
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ export const zhCNLocale: LocalePlugin = {
|
|||||||
"common.ok": "确定",
|
"common.ok": "确定",
|
||||||
"common.language": "语言",
|
"common.language": "语言",
|
||||||
"common.models": "模型",
|
"common.models": "模型",
|
||||||
|
"common.mcp": "MCP",
|
||||||
"common.skills": "技能",
|
"common.skills": "技能",
|
||||||
"common.plugins": "插件",
|
"common.plugins": "插件",
|
||||||
"sidebar.hide": "隐藏侧边栏",
|
"sidebar.hide": "隐藏侧边栏",
|
||||||
@@ -236,6 +237,27 @@ export const zhCNLocale: LocalePlugin = {
|
|||||||
"chat.commandCopy": "复制最后一条助手消息",
|
"chat.commandCopy": "复制最后一条助手消息",
|
||||||
"chat.compacted": "已压缩",
|
"chat.compacted": "已压缩",
|
||||||
"chat.tokensSaved": "节省 {saved}",
|
"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.close": "关闭",
|
||||||
"i18n.copy": "复制",
|
"i18n.copy": "复制",
|
||||||
"i18n.copied": "已复制",
|
"i18n.copied": "已复制",
|
||||||
|
|||||||
Reference in New Issue
Block a user