Convert all MCP-related modal windows to use the unified shadcn/ui Dialog component for consistency with the rest of the application. Changes: - McpPanel: Replace custom modal with Dialog component - Update props from onClose to open/onOpenChange pattern - Use DialogContent, DialogHeader, DialogTitle components - Remove custom backdrop and close button (handled by Dialog) - McpFormModal: Migrate form modal to Dialog - Wrap entire form in Dialog component structure - Use DialogFooter for action buttons - Apply variant="mcp" to maintain green button styling - Remove unused X icon import - McpWizardModal: Convert wizard to Dialog - Replace custom modal structure with Dialog components - Use Button component with variant="mcp" for consistency - Remove unused isLinux and X icon imports - App.tsx: Update McpPanel usage - Remove conditional rendering wrapper - Pass open and onOpenChange props directly - dialog.tsx: Fix dialog overlay and content styling - Change overlay from bg-background/80 to bg-black/50 for consistency - Change content from bg-background to explicit bg-white dark:bg-gray-900 - Ensures opaque backgrounds matching MCP panel style Benefits: - Unified dialog behavior across the application - Consistent styling and animations - Better accessibility with Radix UI primitives - Reduced code duplication - Maintains MCP-specific green color scheme All dialogs now share the same base styling while preserving their unique content and functionality.
416 lines
14 KiB
TypeScript
416 lines
14 KiB
TypeScript
import React, { useEffect, useState } from "react";
|
||
import { useTranslation } from "react-i18next";
|
||
import { Save } from "lucide-react";
|
||
import { Button } from "@/components/ui/button";
|
||
import {
|
||
Dialog,
|
||
DialogContent,
|
||
DialogHeader,
|
||
DialogTitle,
|
||
DialogFooter,
|
||
} from "@/components/ui/dialog";
|
||
import { McpServerSpec } from "../../types";
|
||
|
||
interface McpWizardModalProps {
|
||
isOpen: boolean;
|
||
onClose: () => void;
|
||
onApply: (title: string, json: string) => void;
|
||
onNotify?: (
|
||
message: string,
|
||
type: "success" | "error",
|
||
duration?: number,
|
||
) => void;
|
||
initialTitle?: string;
|
||
initialServer?: McpServerSpec;
|
||
}
|
||
|
||
/**
|
||
* 解析环境变量文本为对象
|
||
*/
|
||
const parseEnvText = (text: string): Record<string, string> => {
|
||
const lines = text
|
||
.split("\n")
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0);
|
||
const env: Record<string, string> = {};
|
||
for (const l of lines) {
|
||
const idx = l.indexOf("=");
|
||
if (idx > 0) {
|
||
const k = l.slice(0, idx).trim();
|
||
const v = l.slice(idx + 1).trim();
|
||
if (k) env[k] = v;
|
||
}
|
||
}
|
||
return env;
|
||
};
|
||
|
||
/**
|
||
* 解析headers文本为对象(支持 KEY: VALUE 或 KEY=VALUE)
|
||
*/
|
||
const parseHeadersText = (text: string): Record<string, string> => {
|
||
const lines = text
|
||
.split("\n")
|
||
.map((l) => l.trim())
|
||
.filter((l) => l.length > 0);
|
||
const headers: Record<string, string> = {};
|
||
for (const l of lines) {
|
||
// 支持 KEY: VALUE 或 KEY=VALUE
|
||
const colonIdx = l.indexOf(":");
|
||
const equalIdx = l.indexOf("=");
|
||
let idx = -1;
|
||
if (colonIdx > 0 && (equalIdx === -1 || colonIdx < equalIdx)) {
|
||
idx = colonIdx;
|
||
} else if (equalIdx > 0) {
|
||
idx = equalIdx;
|
||
}
|
||
if (idx > 0) {
|
||
const k = l.slice(0, idx).trim();
|
||
const v = l.slice(idx + 1).trim();
|
||
if (k) headers[k] = v;
|
||
}
|
||
}
|
||
return headers;
|
||
};
|
||
|
||
/**
|
||
* MCP 配置向导模态框
|
||
* 帮助用户快速生成 MCP JSON 配置
|
||
*/
|
||
const McpWizardModal: React.FC<McpWizardModalProps> = ({
|
||
isOpen,
|
||
onClose,
|
||
onApply,
|
||
onNotify,
|
||
initialTitle,
|
||
initialServer,
|
||
}) => {
|
||
const { t } = useTranslation();
|
||
const [wizardType, setWizardType] = useState<"stdio" | "http">("stdio");
|
||
const [wizardTitle, setWizardTitle] = useState("");
|
||
// stdio 字段
|
||
const [wizardCommand, setWizardCommand] = useState("");
|
||
const [wizardArgs, setWizardArgs] = useState("");
|
||
const [wizardEnv, setWizardEnv] = useState("");
|
||
// http 字段
|
||
const [wizardUrl, setWizardUrl] = useState("");
|
||
const [wizardHeaders, setWizardHeaders] = useState("");
|
||
|
||
// 生成预览 JSON
|
||
const generatePreview = (): string => {
|
||
const config: McpServerSpec = {
|
||
type: wizardType,
|
||
};
|
||
|
||
if (wizardType === "stdio") {
|
||
// stdio 类型必需字段
|
||
config.command = wizardCommand.trim();
|
||
|
||
// 可选字段
|
||
if (wizardArgs.trim()) {
|
||
config.args = wizardArgs
|
||
.split("\n")
|
||
.map((s) => s.trim())
|
||
.filter((s) => s.length > 0);
|
||
}
|
||
|
||
if (wizardEnv.trim()) {
|
||
const env = parseEnvText(wizardEnv);
|
||
if (Object.keys(env).length > 0) {
|
||
config.env = env;
|
||
}
|
||
}
|
||
} else {
|
||
// http 类型必需字段
|
||
config.url = wizardUrl.trim();
|
||
|
||
// 可选字段
|
||
if (wizardHeaders.trim()) {
|
||
const headers = parseHeadersText(wizardHeaders);
|
||
if (Object.keys(headers).length > 0) {
|
||
config.headers = headers;
|
||
}
|
||
}
|
||
}
|
||
|
||
return JSON.stringify(config, null, 2);
|
||
};
|
||
|
||
const handleApply = () => {
|
||
if (!wizardTitle.trim()) {
|
||
onNotify?.(t("mcp.error.idRequired"), "error", 3000);
|
||
return;
|
||
}
|
||
if (wizardType === "stdio" && !wizardCommand.trim()) {
|
||
onNotify?.(t("mcp.error.commandRequired"), "error", 3000);
|
||
return;
|
||
}
|
||
if (wizardType === "http" && !wizardUrl.trim()) {
|
||
onNotify?.(t("mcp.wizard.urlRequired"), "error", 3000);
|
||
return;
|
||
}
|
||
|
||
const json = generatePreview();
|
||
onApply(wizardTitle.trim(), json);
|
||
handleClose();
|
||
};
|
||
|
||
const handleClose = () => {
|
||
// 重置表单
|
||
setWizardType("stdio");
|
||
setWizardTitle("");
|
||
setWizardCommand("");
|
||
setWizardArgs("");
|
||
setWizardEnv("");
|
||
setWizardUrl("");
|
||
setWizardHeaders("");
|
||
onClose();
|
||
};
|
||
|
||
const handleKeyDown = (e: React.KeyboardEvent) => {
|
||
if (e.key === "Enter" && e.metaKey) {
|
||
e.preventDefault();
|
||
handleApply();
|
||
}
|
||
};
|
||
|
||
useEffect(() => {
|
||
if (!isOpen) return;
|
||
|
||
const title = initialTitle ?? "";
|
||
setWizardTitle(title);
|
||
|
||
const resolvedType =
|
||
initialServer?.type ?? (initialServer?.url ? "http" : "stdio");
|
||
|
||
setWizardType(resolvedType);
|
||
|
||
if (resolvedType === "http") {
|
||
setWizardUrl(initialServer?.url ?? "");
|
||
const headersCandidate = initialServer?.headers;
|
||
const headers =
|
||
headersCandidate && typeof headersCandidate === "object"
|
||
? headersCandidate
|
||
: undefined;
|
||
setWizardHeaders(
|
||
headers
|
||
? Object.entries(headers)
|
||
.map(([k, v]) => `${k}: ${v ?? ""}`)
|
||
.join("\n")
|
||
: "",
|
||
);
|
||
setWizardCommand("");
|
||
setWizardArgs("");
|
||
setWizardEnv("");
|
||
return;
|
||
}
|
||
|
||
setWizardCommand(initialServer?.command ?? "");
|
||
const argsValue = initialServer?.args;
|
||
setWizardArgs(Array.isArray(argsValue) ? argsValue.join("\n") : "");
|
||
const envCandidate = initialServer?.env;
|
||
const env =
|
||
envCandidate && typeof envCandidate === "object"
|
||
? envCandidate
|
||
: undefined;
|
||
setWizardEnv(
|
||
env
|
||
? Object.entries(env)
|
||
.map(([k, v]) => `${k}=${v ?? ""}`)
|
||
.join("\n")
|
||
: "",
|
||
);
|
||
setWizardUrl("");
|
||
setWizardHeaders("");
|
||
}, [isOpen]);
|
||
|
||
const preview = generatePreview();
|
||
|
||
return (
|
||
<Dialog open={isOpen} onOpenChange={(open) => !open && handleClose()}>
|
||
<DialogContent className="max-w-2xl max-h-[90vh] flex flex-col">
|
||
<DialogHeader>
|
||
<DialogTitle>{t("mcp.wizard.title")}</DialogTitle>
|
||
</DialogHeader>
|
||
|
||
{/* Content */}
|
||
<div className="flex-1 overflow-y-auto -mx-6 px-6 space-y-4">
|
||
{/* Hint */}
|
||
<div className="rounded-lg border border-blue-200 bg-blue-50 p-3 dark:border-blue-800 dark:bg-blue-900/20">
|
||
<p className="text-sm text-blue-800 dark:text-blue-200">
|
||
{t("mcp.wizard.hint")}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Form Fields */}
|
||
<div className="space-y-4 min-h-[400px]">
|
||
{/* Type */}
|
||
<div>
|
||
<label className="mb-2 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.type")} <span className="text-red-500">*</span>
|
||
</label>
|
||
<div className="flex gap-4">
|
||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
value="stdio"
|
||
checked={wizardType === "stdio"}
|
||
onChange={(e) =>
|
||
setWizardType(e.target.value as "stdio" | "http")
|
||
}
|
||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||
/>
|
||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.typeStdio")}
|
||
</span>
|
||
</label>
|
||
<label className="inline-flex items-center gap-2 cursor-pointer">
|
||
<input
|
||
type="radio"
|
||
value="http"
|
||
checked={wizardType === "http"}
|
||
onChange={(e) =>
|
||
setWizardType(e.target.value as "stdio" | "http")
|
||
}
|
||
className="w-4 h-4 text-emerald-500 bg-white dark:bg-gray-800 border-gray-200 dark:border-gray-700 focus:ring-emerald-500 dark:focus:ring-emerald-400 focus:ring-2"
|
||
/>
|
||
<span className="text-sm text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.typeHttp")}
|
||
</span>
|
||
</label>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Title */}
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.form.title")} <span className="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={wizardTitle}
|
||
onChange={(e) => setWizardTitle(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={t("mcp.form.titlePlaceholder")}
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
|
||
{/* Stdio 类型字段 */}
|
||
{wizardType === "stdio" && (
|
||
<>
|
||
{/* Command */}
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.command")}{" "}
|
||
<span className="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={wizardCommand}
|
||
onChange={(e) => setWizardCommand(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={t("mcp.wizard.commandPlaceholder")}
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
|
||
{/* Args */}
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.args")}
|
||
</label>
|
||
<textarea
|
||
value={wizardArgs}
|
||
onChange={(e) => setWizardArgs(e.target.value)}
|
||
placeholder={t("mcp.wizard.argsPlaceholder")}
|
||
rows={3}
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||
/>
|
||
</div>
|
||
|
||
{/* Env */}
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.env")}
|
||
</label>
|
||
<textarea
|
||
value={wizardEnv}
|
||
onChange={(e) => setWizardEnv(e.target.value)}
|
||
placeholder={t("mcp.wizard.envPlaceholder")}
|
||
rows={3}
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
|
||
{/* HTTP 类型字段 */}
|
||
{wizardType === "http" && (
|
||
<>
|
||
{/* URL */}
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.url")}{" "}
|
||
<span className="text-red-500">*</span>
|
||
</label>
|
||
<input
|
||
type="text"
|
||
value={wizardUrl}
|
||
onChange={(e) => setWizardUrl(e.target.value)}
|
||
onKeyDown={handleKeyDown}
|
||
placeholder={t("mcp.wizard.urlPlaceholder")}
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100"
|
||
/>
|
||
</div>
|
||
|
||
{/* Headers */}
|
||
<div>
|
||
<label className="mb-1 block text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.headers")}
|
||
</label>
|
||
<textarea
|
||
value={wizardHeaders}
|
||
onChange={(e) => setWizardHeaders(e.target.value)}
|
||
placeholder={t("mcp.wizard.headersPlaceholder")}
|
||
rows={3}
|
||
className="w-full rounded-lg border border-gray-200 px-3 py-2 text-sm font-mono focus:outline-none focus:ring-2 focus:ring-emerald-500/20 dark:border-gray-700 dark:bg-gray-800 dark:text-gray-100 resize-y"
|
||
/>
|
||
</div>
|
||
</>
|
||
)}
|
||
</div>
|
||
|
||
{/* Preview */}
|
||
{(wizardCommand ||
|
||
wizardArgs ||
|
||
wizardEnv ||
|
||
wizardUrl ||
|
||
wizardHeaders) && (
|
||
<div className="space-y-2 border-t border-gray-200 pt-4 dark:border-gray-700">
|
||
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">
|
||
{t("mcp.wizard.preview")}
|
||
</h3>
|
||
<pre className="overflow-x-auto rounded-lg bg-gray-50 p-3 text-xs font-mono text-gray-700 dark:bg-gray-800 dark:text-gray-300">
|
||
{preview}
|
||
</pre>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
{/* Footer */}
|
||
<DialogFooter className="gap-3 pt-4">
|
||
<Button type="button" variant="ghost" onClick={handleClose}>
|
||
{t("common.cancel")}
|
||
</Button>
|
||
<Button type="button" variant="mcp" onClick={handleApply}>
|
||
<Save className="h-4 w-4" />
|
||
{t("mcp.wizard.apply")}
|
||
</Button>
|
||
</DialogFooter>
|
||
</DialogContent>
|
||
</Dialog>
|
||
);
|
||
};
|
||
|
||
export default McpWizardModal;
|