1427 lines
50 KiB
TypeScript
1427 lines
50 KiB
TypeScript
"use client"
|
|
|
|
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
|
import {
|
|
Globe,
|
|
Loader2,
|
|
RefreshCw,
|
|
Search,
|
|
ShieldCheck,
|
|
TerminalSquare,
|
|
} from "lucide-react"
|
|
import { useTranslations } from "next-intl"
|
|
import { toast } from "sonner"
|
|
import { Badge } from "@/components/ui/badge"
|
|
import { Button } from "@/components/ui/button"
|
|
import {
|
|
ContextMenu,
|
|
ContextMenuContent,
|
|
ContextMenuItem,
|
|
ContextMenuTrigger,
|
|
} from "@/components/ui/context-menu"
|
|
import {
|
|
Dialog,
|
|
DialogContent,
|
|
DialogDescription,
|
|
DialogFooter,
|
|
DialogHeader,
|
|
DialogTitle,
|
|
} from "@/components/ui/dialog"
|
|
import { Input } from "@/components/ui/input"
|
|
import {
|
|
Select,
|
|
SelectContent,
|
|
SelectItem,
|
|
SelectTrigger,
|
|
SelectValue,
|
|
} from "@/components/ui/select"
|
|
import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"
|
|
import { Textarea } from "@/components/ui/textarea"
|
|
import {
|
|
mcpGetMarketplaceServerDetail,
|
|
mcpInstallFromMarketplace,
|
|
mcpListMarketplaces,
|
|
mcpRemoveServer,
|
|
mcpScanLocal,
|
|
mcpSearchMarketplace,
|
|
mcpUpsertLocalServer,
|
|
} from "@/lib/api"
|
|
import { cn } from "@/lib/utils"
|
|
import type {
|
|
LocalMcpServer,
|
|
McpAppType,
|
|
McpMarketplaceItem,
|
|
McpMarketplaceInstallOption,
|
|
McpMarketplaceProvider,
|
|
McpMarketplaceServerDetail,
|
|
} from "@/lib/types"
|
|
|
|
type LeftTab = "local" | "market"
|
|
|
|
type Selection =
|
|
| { kind: "local"; id: string }
|
|
| { kind: "market"; id: string }
|
|
| null
|
|
|
|
type McpTranslator = (
|
|
key: string,
|
|
values?: Record<string, string | number>
|
|
) => string
|
|
|
|
const APP_OPTIONS: { value: McpAppType; label: string }[] = [
|
|
{ value: "claude_code", label: "Claude" },
|
|
{ value: "codex", label: "Codex" },
|
|
{ value: "open_code", label: "OpenCode" },
|
|
]
|
|
|
|
function isObject(value: unknown): value is Record<string, unknown> {
|
|
return Boolean(value) && typeof value === "object" && !Array.isArray(value)
|
|
}
|
|
|
|
function readString(spec: Record<string, unknown>, key: string): string | null {
|
|
const raw = spec[key]
|
|
if (typeof raw !== "string") return null
|
|
const trimmed = raw.trim()
|
|
return trimmed ? trimmed : null
|
|
}
|
|
|
|
function specSummary(spec: Record<string, unknown>, t: McpTranslator): string {
|
|
const typ = readString(spec, "type") ?? "stdio"
|
|
|
|
if (typ === "stdio") {
|
|
const command = readString(spec, "command") ?? t("summary.missingCommand")
|
|
const rawArgs = spec.args
|
|
const args = Array.isArray(rawArgs)
|
|
? rawArgs
|
|
.map((item) => (typeof item === "string" ? item.trim() : ""))
|
|
.filter(Boolean)
|
|
: []
|
|
return args.length > 0 ? `${command} ${args.join(" ")}` : command
|
|
}
|
|
|
|
const url = readString(spec, "url") ?? t("summary.missingUrl")
|
|
return `${typ}: ${url}`
|
|
}
|
|
|
|
function protocolBadgeLabel(protocol: string, t: McpTranslator): string {
|
|
if (protocol === "stdio") return t("protocol.stdio")
|
|
if (protocol === "sse") return "SSE"
|
|
if (protocol === "http") return "HTTP"
|
|
return protocol
|
|
}
|
|
|
|
function defaultParamDraft(
|
|
option: McpMarketplaceInstallOption | null
|
|
): Record<string, string> {
|
|
if (!option) return {}
|
|
const draft: Record<string, string> = {}
|
|
for (const field of option.parameters) {
|
|
if (field.default_value === null || field.default_value === undefined)
|
|
continue
|
|
if (typeof field.default_value === "string") {
|
|
draft[field.key] = field.default_value
|
|
continue
|
|
}
|
|
if (
|
|
typeof field.default_value === "number" ||
|
|
typeof field.default_value === "boolean"
|
|
) {
|
|
draft[field.key] = String(field.default_value)
|
|
continue
|
|
}
|
|
draft[field.key] = JSON.stringify(field.default_value)
|
|
}
|
|
return draft
|
|
}
|
|
|
|
function parseParameterValues(
|
|
option: McpMarketplaceInstallOption | null,
|
|
draft: Record<string, string>,
|
|
t: McpTranslator
|
|
): { values: Record<string, unknown>; error: string | null } {
|
|
if (!option) return { values: {}, error: t("errors.selectInstallProtocol") }
|
|
|
|
const values: Record<string, unknown> = {}
|
|
for (const field of option.parameters) {
|
|
const raw = (draft[field.key] ?? "").trim()
|
|
|
|
if (!raw) {
|
|
if (field.required && field.default_value == null) {
|
|
return {
|
|
values: {},
|
|
error: t("errors.fieldRequired", { field: field.label }),
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (field.kind === "boolean") {
|
|
if (raw !== "true" && raw !== "false") {
|
|
return {
|
|
values: {},
|
|
error: t("errors.fieldNeedsBoolean", { field: field.label }),
|
|
}
|
|
}
|
|
values[field.key] = raw === "true"
|
|
continue
|
|
}
|
|
|
|
if (field.kind === "number") {
|
|
const next = Number(raw)
|
|
if (!Number.isFinite(next)) {
|
|
return {
|
|
values: {},
|
|
error: t("errors.fieldNeedsNumber", { field: field.label }),
|
|
}
|
|
}
|
|
values[field.key] = next
|
|
continue
|
|
}
|
|
|
|
if (field.kind === "integer") {
|
|
const next = Number(raw)
|
|
if (!Number.isInteger(next)) {
|
|
return {
|
|
values: {},
|
|
error: t("errors.fieldNeedsInteger", { field: field.label }),
|
|
}
|
|
}
|
|
values[field.key] = next
|
|
continue
|
|
}
|
|
|
|
if (field.kind === "json") {
|
|
try {
|
|
values[field.key] = JSON.parse(raw)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
return {
|
|
values: {},
|
|
error: t("errors.fieldInvalidJson", {
|
|
field: field.label,
|
|
message,
|
|
}),
|
|
}
|
|
}
|
|
continue
|
|
}
|
|
|
|
if (field.enum_values.length > 0 && !field.enum_values.includes(raw)) {
|
|
return {
|
|
values: {},
|
|
error: t("errors.fieldOutOfRange", { field: field.label }),
|
|
}
|
|
}
|
|
|
|
values[field.key] = raw
|
|
}
|
|
|
|
return { values, error: null }
|
|
}
|
|
|
|
function normalizeApps(apps: McpAppType[]): McpAppType[] {
|
|
return [...new Set(apps)]
|
|
}
|
|
|
|
function appsToDraft(apps: McpAppType[]): Record<McpAppType, boolean> {
|
|
const appSet = new Set(apps)
|
|
return {
|
|
claude_code: appSet.has("claude_code"),
|
|
codex: appSet.has("codex"),
|
|
open_code: appSet.has("open_code"),
|
|
}
|
|
}
|
|
|
|
function selectedAppsFromDraft(
|
|
draft: Record<McpAppType, boolean>
|
|
): McpAppType[] {
|
|
return APP_OPTIONS.filter((item) => draft[item.value]).map(
|
|
(item) => item.value
|
|
)
|
|
}
|
|
|
|
function parseJsonObject(
|
|
text: string,
|
|
name: string,
|
|
t: McpTranslator
|
|
): Record<string, unknown> {
|
|
const trimmed = text.trim()
|
|
if (!trimmed) {
|
|
throw new Error(t("errors.jsonEmpty", { name }))
|
|
}
|
|
|
|
let parsed: unknown
|
|
try {
|
|
parsed = JSON.parse(trimmed)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
throw new Error(t("errors.jsonInvalid", { name, message }))
|
|
}
|
|
|
|
if (!isObject(parsed)) {
|
|
throw new Error(t("errors.jsonMustBeObject", { name }))
|
|
}
|
|
|
|
return parsed
|
|
}
|
|
|
|
export function McpSettings() {
|
|
const t = useTranslations("McpSettings")
|
|
const mcpT = t as unknown as McpTranslator
|
|
const [loading, setLoading] = useState(true)
|
|
const [loadingError, setLoadingError] = useState<string | null>(null)
|
|
|
|
const [leftTab, setLeftTab] = useState<LeftTab>("local")
|
|
const [selection, setSelection] = useState<Selection>(null)
|
|
|
|
const [installedServers, setInstalledServers] = useState<LocalMcpServer[]>([])
|
|
const [localFilter, setLocalFilter] = useState("")
|
|
|
|
const [providers, setProviders] = useState<McpMarketplaceProvider[]>([])
|
|
const [selectedProvider, setSelectedProvider] = useState("")
|
|
const [marketQuery, setMarketQuery] = useState("")
|
|
const marketQueryRef = useRef("")
|
|
const [searching, setSearching] = useState(false)
|
|
const [searchError, setSearchError] = useState<string | null>(null)
|
|
const [searchResults, setSearchResults] = useState<McpMarketplaceItem[]>([])
|
|
|
|
const [marketDetail, setMarketDetail] =
|
|
useState<McpMarketplaceServerDetail | null>(null)
|
|
const [marketDetailLoading, setMarketDetailLoading] = useState(false)
|
|
const [marketDetailError, setMarketDetailError] = useState<string | null>(
|
|
null
|
|
)
|
|
const [marketSpecText, setMarketSpecText] = useState("")
|
|
const [marketSpecDirty, setMarketSpecDirty] = useState(false)
|
|
const [selectedInstallOptionId, setSelectedInstallOptionId] = useState("")
|
|
const [installParamDraft, setInstallParamDraft] = useState<
|
|
Record<string, string>
|
|
>({})
|
|
|
|
const [localSpecText, setLocalSpecText] = useState("")
|
|
const [localAppsDraft, setLocalAppsDraft] = useState<
|
|
Record<McpAppType, boolean>
|
|
>(appsToDraft([]))
|
|
|
|
const [installDialogOpen, setInstallDialogOpen] = useState(false)
|
|
const [installAppsDraft, setInstallAppsDraft] = useState<
|
|
Record<McpAppType, boolean>
|
|
>(appsToDraft(APP_OPTIONS.map((x) => x.value)))
|
|
|
|
const [runningAction, setRunningAction] = useState<string | null>(null)
|
|
|
|
const selectedLocal = useMemo(() => {
|
|
if (selection?.kind !== "local") return null
|
|
return installedServers.find((item) => item.id === selection.id) ?? null
|
|
}, [installedServers, selection])
|
|
|
|
const selectedMarketItem = useMemo(() => {
|
|
if (selection?.kind !== "market") return null
|
|
return searchResults.find((item) => item.server_id === selection.id) ?? null
|
|
}, [searchResults, selection])
|
|
|
|
const selectedInstallOption = useMemo(() => {
|
|
if (!marketDetail) return null
|
|
return (
|
|
marketDetail.install_options.find(
|
|
(item) => item.id === selectedInstallOptionId
|
|
) ??
|
|
marketDetail.install_options[0] ??
|
|
null
|
|
)
|
|
}, [marketDetail, selectedInstallOptionId])
|
|
|
|
const filteredLocalServers = useMemo(() => {
|
|
const q = localFilter.trim().toLowerCase()
|
|
if (!q) return installedServers
|
|
return installedServers.filter((item) => {
|
|
if (item.id.toLowerCase().includes(q)) return true
|
|
const spec = isObject(item.spec) ? item.spec : {}
|
|
return specSummary(spec, mcpT).toLowerCase().includes(q)
|
|
})
|
|
}, [installedServers, localFilter, mcpT])
|
|
|
|
const refreshLocalServers = useCallback(async () => {
|
|
const servers = await mcpScanLocal()
|
|
setInstalledServers(servers)
|
|
return servers
|
|
}, [])
|
|
|
|
const loadInitial = useCallback(async () => {
|
|
setLoading(true)
|
|
setLoadingError(null)
|
|
|
|
try {
|
|
const [servers, marketProviders] = await Promise.all([
|
|
mcpScanLocal(),
|
|
mcpListMarketplaces(),
|
|
])
|
|
setInstalledServers(servers)
|
|
setProviders(marketProviders)
|
|
setSelectedProvider(
|
|
(current) => current || marketProviders[0]?.id || "official_registry"
|
|
)
|
|
|
|
if (servers[0]) {
|
|
setSelection({ kind: "local", id: servers[0].id })
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
setLoadingError(message)
|
|
} finally {
|
|
setLoading(false)
|
|
}
|
|
}, [])
|
|
|
|
useEffect(() => {
|
|
loadInitial().catch((err) => {
|
|
console.error("[Settings] load MCP settings failed:", err)
|
|
})
|
|
}, [loadInitial])
|
|
|
|
useEffect(() => {
|
|
if (!selectedLocal) return
|
|
const nextSpec = JSON.stringify(selectedLocal.spec, null, 2)
|
|
setLocalSpecText(nextSpec)
|
|
setLocalAppsDraft(appsToDraft(selectedLocal.apps))
|
|
}, [selectedLocal])
|
|
|
|
useEffect(() => {
|
|
if (selection?.kind !== "market" || !selectedMarketItem) {
|
|
setMarketDetail(null)
|
|
setMarketDetailError(null)
|
|
setMarketSpecText("")
|
|
setMarketSpecDirty(false)
|
|
setSelectedInstallOptionId("")
|
|
setInstallParamDraft({})
|
|
return
|
|
}
|
|
|
|
setMarketDetailLoading(true)
|
|
setMarketDetailError(null)
|
|
|
|
mcpGetMarketplaceServerDetail({
|
|
providerId: selectedMarketItem.provider_id,
|
|
serverId: selectedMarketItem.server_id,
|
|
})
|
|
.then((detail) => {
|
|
setMarketDetail(detail)
|
|
const defaultOption =
|
|
detail.install_options.find(
|
|
(item) => item.id === detail.default_option_id
|
|
) ??
|
|
detail.install_options[0] ??
|
|
null
|
|
setSelectedInstallOptionId(defaultOption?.id ?? "")
|
|
setInstallParamDraft(defaultParamDraft(defaultOption))
|
|
setMarketSpecText(
|
|
JSON.stringify(defaultOption?.spec ?? detail.spec, null, 2)
|
|
)
|
|
setMarketSpecDirty(false)
|
|
})
|
|
.catch((err) => {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
setMarketDetailError(message)
|
|
setMarketDetail(null)
|
|
setMarketSpecText("")
|
|
setMarketSpecDirty(false)
|
|
setSelectedInstallOptionId("")
|
|
setInstallParamDraft({})
|
|
})
|
|
.finally(() => {
|
|
setMarketDetailLoading(false)
|
|
})
|
|
}, [selection, selectedMarketItem])
|
|
|
|
const executeSearch = useCallback(
|
|
async ({
|
|
providerId,
|
|
query,
|
|
}: {
|
|
providerId: string
|
|
query: string | null
|
|
}) => {
|
|
if (!providerId) return
|
|
|
|
setSearching(true)
|
|
setSearchError(null)
|
|
|
|
try {
|
|
const results = await mcpSearchMarketplace({
|
|
providerId,
|
|
query: query?.trim() || null,
|
|
limit: 30,
|
|
})
|
|
setSearchResults(results)
|
|
|
|
if (results[0]) {
|
|
setSelection((current) => {
|
|
if (current?.kind === "market") {
|
|
const hit = results.some((item) => item.server_id === current.id)
|
|
if (hit) return current
|
|
}
|
|
return { kind: "market", id: results[0].server_id }
|
|
})
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
setSearchError(message)
|
|
} finally {
|
|
setSearching(false)
|
|
}
|
|
},
|
|
[]
|
|
)
|
|
|
|
useEffect(() => {
|
|
marketQueryRef.current = marketQuery
|
|
}, [marketQuery])
|
|
|
|
useEffect(() => {
|
|
if (leftTab !== "market" || !selectedProvider) return
|
|
executeSearch({
|
|
providerId: selectedProvider,
|
|
query: marketQueryRef.current,
|
|
}).catch((err) => {
|
|
console.error("[Settings] auto search MCP marketplace failed:", err)
|
|
})
|
|
}, [executeSearch, leftTab, selectedProvider])
|
|
|
|
const uninstallServer = useCallback(
|
|
async (serverId: string) => {
|
|
const action = `uninstall:${serverId}`
|
|
setRunningAction(action)
|
|
|
|
try {
|
|
await mcpRemoveServer(serverId)
|
|
const next = await refreshLocalServers()
|
|
toast.success(t("toasts.uninstalled"))
|
|
|
|
setSelection((current) => {
|
|
if (current?.kind !== "local" || current.id !== serverId)
|
|
return current
|
|
if (next[0]) return { kind: "local", id: next[0].id }
|
|
return null
|
|
})
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("toasts.uninstallFailed", { message }))
|
|
} finally {
|
|
setRunningAction(null)
|
|
}
|
|
},
|
|
[refreshLocalServers, t]
|
|
)
|
|
|
|
const saveLocalServer = useCallback(async () => {
|
|
if (!selectedLocal) return
|
|
|
|
let parsedSpec: Record<string, unknown>
|
|
try {
|
|
parsedSpec = parseJsonObject(
|
|
localSpecText,
|
|
t("jsonNames.localConfig"),
|
|
mcpT
|
|
)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(message)
|
|
return
|
|
}
|
|
|
|
const apps = normalizeApps(selectedAppsFromDraft(localAppsDraft))
|
|
if (apps.length === 0) {
|
|
toast.error(t("toasts.selectAtLeastOneApp"))
|
|
return
|
|
}
|
|
|
|
const action = `save:${selectedLocal.id}`
|
|
setRunningAction(action)
|
|
|
|
try {
|
|
await mcpUpsertLocalServer({
|
|
serverId: selectedLocal.id,
|
|
spec: parsedSpec,
|
|
apps,
|
|
})
|
|
const next = await refreshLocalServers()
|
|
toast.success(t("toasts.saveSuccess"))
|
|
|
|
const updated = next.find((item) => item.id === selectedLocal.id)
|
|
if (updated) {
|
|
setSelection({ kind: "local", id: updated.id })
|
|
setLocalSpecText(JSON.stringify(updated.spec, null, 2))
|
|
setLocalAppsDraft(appsToDraft(updated.apps))
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("toasts.saveFailed", { message }))
|
|
} finally {
|
|
setRunningAction(null)
|
|
}
|
|
}, [
|
|
localAppsDraft,
|
|
localSpecText,
|
|
mcpT,
|
|
refreshLocalServers,
|
|
selectedLocal,
|
|
t,
|
|
])
|
|
|
|
const switchInstallOption = useCallback(
|
|
(optionId: string) => {
|
|
if (!marketDetail) return
|
|
const option =
|
|
marketDetail.install_options.find((item) => item.id === optionId) ??
|
|
marketDetail.install_options[0] ??
|
|
null
|
|
setSelectedInstallOptionId(option?.id ?? "")
|
|
setInstallParamDraft(defaultParamDraft(option))
|
|
setMarketSpecText(
|
|
JSON.stringify(option?.spec ?? marketDetail.spec, null, 2)
|
|
)
|
|
setMarketSpecDirty(false)
|
|
},
|
|
[marketDetail]
|
|
)
|
|
|
|
const openInstallDialog = useCallback(() => {
|
|
if (!marketDetail) return
|
|
setInstallAppsDraft(appsToDraft(APP_OPTIONS.map((item) => item.value)))
|
|
const option =
|
|
marketDetail.install_options.find(
|
|
(item) => item.id === selectedInstallOptionId
|
|
) ??
|
|
marketDetail.install_options[0] ??
|
|
null
|
|
setSelectedInstallOptionId(option?.id ?? "")
|
|
setInstallParamDraft(defaultParamDraft(option))
|
|
setInstallDialogOpen(true)
|
|
}, [marketDetail, selectedInstallOptionId])
|
|
|
|
const installMarketServer = useCallback(async () => {
|
|
if (!marketDetail) return
|
|
|
|
const parsedParams = parseParameterValues(
|
|
selectedInstallOption,
|
|
installParamDraft,
|
|
mcpT
|
|
)
|
|
if (parsedParams.error) {
|
|
toast.error(parsedParams.error)
|
|
return
|
|
}
|
|
|
|
let specOverride: Record<string, unknown> | null = null
|
|
const baselineText = JSON.stringify(
|
|
selectedInstallOption?.spec ?? marketDetail.spec,
|
|
null,
|
|
2
|
|
).trim()
|
|
const currentSpecText = marketSpecText.trim()
|
|
if (marketSpecDirty && currentSpecText !== baselineText) {
|
|
try {
|
|
specOverride = parseJsonObject(
|
|
marketSpecText,
|
|
t("jsonNames.installConfig"),
|
|
mcpT
|
|
)
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(message)
|
|
return
|
|
}
|
|
}
|
|
|
|
const apps = normalizeApps(selectedAppsFromDraft(installAppsDraft))
|
|
if (apps.length === 0) {
|
|
toast.error(t("toasts.selectAtLeastOneApp"))
|
|
return
|
|
}
|
|
|
|
const action = `install:${marketDetail.server_id}`
|
|
setRunningAction(action)
|
|
|
|
try {
|
|
await mcpInstallFromMarketplace({
|
|
providerId: marketDetail.provider_id,
|
|
serverId: marketDetail.server_id,
|
|
apps,
|
|
optionId: selectedInstallOption?.id ?? null,
|
|
protocol: selectedInstallOption?.protocol ?? null,
|
|
parameterValues: parsedParams.values,
|
|
specOverride,
|
|
})
|
|
const nextLocal = await refreshLocalServers()
|
|
toast.success(t("toasts.installed", { name: marketDetail.name }))
|
|
setInstallDialogOpen(false)
|
|
setLeftTab("local")
|
|
|
|
const installed = nextLocal.find(
|
|
(item) => item.id === marketDetail.server_id
|
|
)
|
|
if (installed) {
|
|
setSelection({ kind: "local", id: installed.id })
|
|
}
|
|
} catch (err) {
|
|
const message = err instanceof Error ? err.message : String(err)
|
|
toast.error(t("toasts.installFailed", { message }))
|
|
} finally {
|
|
setRunningAction(null)
|
|
}
|
|
}, [
|
|
installAppsDraft,
|
|
installParamDraft,
|
|
marketDetail,
|
|
marketSpecDirty,
|
|
marketSpecText,
|
|
mcpT,
|
|
refreshLocalServers,
|
|
selectedInstallOption,
|
|
t,
|
|
])
|
|
|
|
if (loading) {
|
|
return (
|
|
<div className="h-full flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t("loading")}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Dialog open={installDialogOpen} onOpenChange={setInstallDialogOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>{t("installDialog.title")}</DialogTitle>
|
|
<DialogDescription>
|
|
{marketDetail
|
|
? t("installDialog.descriptionWithName", {
|
|
name: marketDetail.name,
|
|
})
|
|
: t("installDialog.description")}
|
|
</DialogDescription>
|
|
</DialogHeader>
|
|
|
|
<div className="space-y-4 text-sm">
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("installDialog.protocol")}
|
|
</div>
|
|
<Select
|
|
value={selectedInstallOption?.id ?? ""}
|
|
onValueChange={switchInstallOption}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={t("installDialog.selectProtocol")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{(marketDetail?.install_options ?? []).map((option) => (
|
|
<SelectItem key={option.id} value={option.id}>
|
|
{protocolBadgeLabel(option.protocol, mcpT)} ·{" "}
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
|
|
{selectedInstallOption?.parameters.length ? (
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("installDialog.parameters")}
|
|
</div>
|
|
<div className="max-h-56 overflow-auto space-y-2 pr-1">
|
|
{selectedInstallOption.parameters.map((field) => {
|
|
const raw = installParamDraft[field.key] ?? ""
|
|
return (
|
|
<div key={field.key} className="space-y-1">
|
|
<div className="text-xs font-medium">
|
|
{field.label}
|
|
{field.required ? (
|
|
<span className="text-red-500 ml-1">*</span>
|
|
) : null}
|
|
{field.location ? (
|
|
<span className="text-muted-foreground ml-2">
|
|
{field.location}
|
|
</span>
|
|
) : null}
|
|
</div>
|
|
{field.kind === "boolean" ? (
|
|
<Select
|
|
value={raw}
|
|
onValueChange={(value) =>
|
|
setInstallParamDraft((prev) => ({
|
|
...prev,
|
|
[field.key]: value,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={t(
|
|
"installDialog.booleanPlaceholder"
|
|
)}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
<SelectItem value="true">true</SelectItem>
|
|
<SelectItem value="false">false</SelectItem>
|
|
</SelectContent>
|
|
</Select>
|
|
) : field.enum_values.length > 0 ? (
|
|
<Select
|
|
value={raw}
|
|
onValueChange={(value) =>
|
|
setInstallParamDraft((prev) => ({
|
|
...prev,
|
|
[field.key]: value,
|
|
}))
|
|
}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={t("installDialog.selectOneValue")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{field.enum_values.map((value) => (
|
|
<SelectItem key={value} value={value}>
|
|
{value}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
) : field.kind === "json" ? (
|
|
<Textarea
|
|
value={raw}
|
|
onChange={(event) =>
|
|
setInstallParamDraft((prev) => ({
|
|
...prev,
|
|
[field.key]: event.target.value,
|
|
}))
|
|
}
|
|
className="min-h-20 font-mono text-xs"
|
|
placeholder={field.placeholder ?? ""}
|
|
/>
|
|
) : (
|
|
<Input
|
|
type={field.secret ? "password" : "text"}
|
|
value={raw}
|
|
onChange={(event) =>
|
|
setInstallParamDraft((prev) => ({
|
|
...prev,
|
|
[field.key]: event.target.value,
|
|
}))
|
|
}
|
|
placeholder={field.placeholder ?? ""}
|
|
/>
|
|
)}
|
|
{field.description ? (
|
|
<div className="text-[11px] text-muted-foreground leading-5">
|
|
{field.description}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("installDialog.targetApps")}
|
|
</div>
|
|
{APP_OPTIONS.map((app) => (
|
|
<label
|
|
key={app.value}
|
|
className="inline-flex w-full items-center gap-2 rounded-md border px-2 py-1.5"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={installAppsDraft[app.value]}
|
|
onChange={(event) => {
|
|
setInstallAppsDraft((prev) => ({
|
|
...prev,
|
|
[app.value]: event.target.checked,
|
|
}))
|
|
}}
|
|
/>
|
|
<span>{app.label}</span>
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<DialogFooter>
|
|
<Button
|
|
variant="outline"
|
|
onClick={() => setInstallDialogOpen(false)}
|
|
disabled={Boolean(runningAction?.startsWith("install:"))}
|
|
>
|
|
{t("actions.cancel")}
|
|
</Button>
|
|
<Button
|
|
onClick={() => {
|
|
installMarketServer().catch((err) => {
|
|
console.error("[Settings] install MCP failed:", err)
|
|
})
|
|
}}
|
|
disabled={Boolean(runningAction?.startsWith("install:"))}
|
|
>
|
|
{runningAction?.startsWith("install:") ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("actions.installing")}
|
|
</>
|
|
) : (
|
|
t("actions.confirmInstall")
|
|
)}
|
|
</Button>
|
|
</DialogFooter>
|
|
</DialogContent>
|
|
</Dialog>
|
|
|
|
<div className="h-full min-h-0 grid grid-cols-1 gap-4 lg:grid-cols-[360px_1fr]">
|
|
<section className="min-h-0 rounded-xl border bg-card p-3">
|
|
<Tabs
|
|
value={leftTab}
|
|
onValueChange={(value) => setLeftTab(value as LeftTab)}
|
|
className="h-full"
|
|
>
|
|
<TabsList className="w-full">
|
|
<TabsTrigger value="local" className="flex-1">
|
|
{t("tabs.local")}
|
|
</TabsTrigger>
|
|
<TabsTrigger value="market" className="flex-1">
|
|
{t("tabs.market")}
|
|
</TabsTrigger>
|
|
</TabsList>
|
|
|
|
<TabsContent value="local" className="h-full min-h-0 pt-2">
|
|
<div className="flex items-center gap-2 pb-2">
|
|
<Input
|
|
value={localFilter}
|
|
onChange={(event) => setLocalFilter(event.target.value)}
|
|
placeholder={t("local.filterPlaceholder")}
|
|
/>
|
|
<Button
|
|
size="icon"
|
|
variant="outline"
|
|
onClick={() => {
|
|
refreshLocalServers().catch((err) => {
|
|
console.error("[Settings] refresh local MCP failed:", err)
|
|
})
|
|
}}
|
|
>
|
|
<RefreshCw className="h-3.5 w-3.5" />
|
|
</Button>
|
|
</div>
|
|
|
|
{loadingError ? (
|
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
|
{t("local.loadFailed", { message: loadingError })}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="h-[calc(100%-48px)] overflow-auto space-y-1">
|
|
{filteredLocalServers.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
|
{t("local.empty")}
|
|
</div>
|
|
) : (
|
|
filteredLocalServers.map((server) => {
|
|
const active =
|
|
selection?.kind === "local" && selection.id === server.id
|
|
const spec = isObject(server.spec) ? server.spec : {}
|
|
return (
|
|
<ContextMenu key={server.id}>
|
|
<ContextMenuTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"w-full rounded-md border p-2 text-left transition-colors",
|
|
active
|
|
? "border-primary bg-primary/5"
|
|
: "hover:bg-muted/60"
|
|
)}
|
|
onClick={() => {
|
|
setSelection({ kind: "local", id: server.id })
|
|
}}
|
|
>
|
|
<div className="text-sm font-medium break-all">
|
|
{server.id}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground line-clamp-2 break-all">
|
|
{specSummary(spec, mcpT)}
|
|
</div>
|
|
</button>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
variant="destructive"
|
|
onClick={() => {
|
|
uninstallServer(server.id).catch((err) => {
|
|
console.error(
|
|
"[Settings] uninstall MCP failed:",
|
|
err
|
|
)
|
|
})
|
|
}}
|
|
>
|
|
{t("actions.uninstall")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
<TabsContent value="market" className="h-full min-h-0 pt-2">
|
|
<div className="space-y-2 pb-2">
|
|
<Select
|
|
value={selectedProvider}
|
|
onValueChange={setSelectedProvider}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue placeholder={t("market.selectMarketplace")} />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{providers.map((provider) => (
|
|
<SelectItem key={provider.id} value={provider.id}>
|
|
{provider.name}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
|
|
<div className="flex gap-2">
|
|
<Input
|
|
value={marketQuery}
|
|
onChange={(event) => setMarketQuery(event.target.value)}
|
|
placeholder={t("market.searchPlaceholder")}
|
|
onKeyDown={(event) => {
|
|
if (event.key !== "Enter") return
|
|
executeSearch({
|
|
providerId: selectedProvider,
|
|
query: marketQuery,
|
|
}).catch((err) => {
|
|
console.error(
|
|
"[Settings] search MCP marketplace failed:",
|
|
err
|
|
)
|
|
})
|
|
}}
|
|
/>
|
|
<Button
|
|
onClick={() => {
|
|
executeSearch({
|
|
providerId: selectedProvider,
|
|
query: marketQuery,
|
|
}).catch((err) => {
|
|
console.error(
|
|
"[Settings] search MCP marketplace failed:",
|
|
err
|
|
)
|
|
})
|
|
}}
|
|
disabled={searching || !selectedProvider}
|
|
>
|
|
{searching ? (
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
) : (
|
|
<Search className="h-3.5 w-3.5" />
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{searchError ? (
|
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
|
{t("market.searchFailed", { message: searchError })}
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="h-[calc(100%-106px)] overflow-auto space-y-1">
|
|
{searching ? (
|
|
<div className="h-full min-h-24 rounded-md border border-dashed flex items-center justify-center gap-2 text-xs text-muted-foreground">
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("market.loadingList")}
|
|
</div>
|
|
) : searchResults.length === 0 ? (
|
|
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
|
{t("market.empty")}
|
|
</div>
|
|
) : (
|
|
searchResults.map((item) => {
|
|
const active =
|
|
selection?.kind === "market" &&
|
|
selection.id === item.server_id
|
|
return (
|
|
<ContextMenu
|
|
key={`${item.provider_id}:${item.server_id}`}
|
|
>
|
|
<ContextMenuTrigger asChild>
|
|
<button
|
|
className={cn(
|
|
"w-full rounded-md border p-2 text-left transition-colors",
|
|
active
|
|
? "border-primary bg-primary/5"
|
|
: "hover:bg-muted/60"
|
|
)}
|
|
onClick={() => {
|
|
setSelection({
|
|
kind: "market",
|
|
id: item.server_id,
|
|
})
|
|
}}
|
|
>
|
|
<div className="flex items-start gap-2">
|
|
<div className="mt-0.5 h-7 w-7 overflow-hidden rounded-md border bg-muted/40 shrink-0">
|
|
{item.icon_url ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={item.icon_url}
|
|
alt={item.name}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center text-[10px] text-muted-foreground">
|
|
MCP
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0 flex-1">
|
|
<div className="text-sm font-medium break-all">
|
|
{item.name}
|
|
</div>
|
|
<div className="text-xs text-muted-foreground break-all">
|
|
{item.server_id}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="mt-2 flex flex-wrap gap-1">
|
|
{item.protocols.map((protocol) => (
|
|
<Badge
|
|
key={`${item.server_id}-${protocol}`}
|
|
variant="secondary"
|
|
className="text-[10px]"
|
|
>
|
|
{protocolBadgeLabel(protocol, mcpT)}
|
|
</Badge>
|
|
))}
|
|
{item.latest_version ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px]"
|
|
>
|
|
v{item.latest_version}
|
|
</Badge>
|
|
) : null}
|
|
{item.verified ? (
|
|
<Badge className="text-[10px]">
|
|
{t("badges.verified")}
|
|
</Badge>
|
|
) : null}
|
|
{typeof item.downloads === "number" ? (
|
|
<Badge
|
|
variant="outline"
|
|
className="text-[10px]"
|
|
>
|
|
{t("badges.uses", { count: item.downloads })}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
</button>
|
|
</ContextMenuTrigger>
|
|
<ContextMenuContent>
|
|
<ContextMenuItem
|
|
onClick={() => {
|
|
setSelection({
|
|
kind: "market",
|
|
id: item.server_id,
|
|
})
|
|
}}
|
|
>
|
|
{t("actions.viewDetails")}
|
|
</ContextMenuItem>
|
|
</ContextMenuContent>
|
|
</ContextMenu>
|
|
)
|
|
})
|
|
)}
|
|
</div>
|
|
</TabsContent>
|
|
</Tabs>
|
|
</section>
|
|
|
|
<section className="min-h-0 rounded-xl border bg-card p-4 overflow-auto">
|
|
{selection?.kind === "local" && selectedLocal ? (
|
|
<div className="space-y-4">
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div>
|
|
<h2 className="text-base font-semibold break-all">
|
|
{selectedLocal.id}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground mt-1">
|
|
{t("local.description")}
|
|
</p>
|
|
</div>
|
|
<Button
|
|
variant="destructive"
|
|
onClick={() => {
|
|
uninstallServer(selectedLocal.id).catch((err) => {
|
|
console.error("[Settings] uninstall MCP failed:", err)
|
|
})
|
|
}}
|
|
disabled={runningAction === `uninstall:${selectedLocal.id}`}
|
|
>
|
|
{runningAction === `uninstall:${selectedLocal.id}` ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("actions.uninstalling")}
|
|
</>
|
|
) : (
|
|
t("actions.uninstall")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("local.enabledApps")}
|
|
</div>
|
|
<div className="flex flex-wrap gap-2">
|
|
{APP_OPTIONS.map((app) => (
|
|
<label
|
|
key={app.value}
|
|
className="inline-flex items-center gap-1.5 rounded-md border px-2 py-1 text-xs"
|
|
>
|
|
<input
|
|
type="checkbox"
|
|
checked={localAppsDraft[app.value]}
|
|
onChange={(event) => {
|
|
setLocalAppsDraft((prev) => ({
|
|
...prev,
|
|
[app.value]: event.target.checked,
|
|
}))
|
|
}}
|
|
/>
|
|
{app.label}
|
|
</label>
|
|
))}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("local.configJson")}
|
|
</div>
|
|
<Textarea
|
|
value={localSpecText}
|
|
onChange={(event) => setLocalSpecText(event.target.value)}
|
|
className="min-h-[360px] font-mono text-xs"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex justify-end">
|
|
<Button
|
|
onClick={() => {
|
|
saveLocalServer().catch((err) => {
|
|
console.error("[Settings] save local MCP failed:", err)
|
|
})
|
|
}}
|
|
disabled={runningAction === `save:${selectedLocal.id}`}
|
|
>
|
|
{runningAction === `save:${selectedLocal.id}` ? (
|
|
<>
|
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
|
{t("actions.saving")}
|
|
</>
|
|
) : (
|
|
t("actions.save")
|
|
)}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{selection?.kind === "market" ? (
|
|
<div className="space-y-4">
|
|
{marketDetailLoading ? (
|
|
<div className="h-40 flex items-center justify-center gap-2 text-sm text-muted-foreground">
|
|
<Loader2 className="h-4 w-4 animate-spin" />
|
|
{t("market.loadingDetail")}
|
|
</div>
|
|
) : marketDetailError ? (
|
|
<div className="rounded-md border border-red-500/30 bg-red-500/5 px-3 py-2 text-xs text-red-400">
|
|
{t("market.detailLoadFailed", { message: marketDetailError })}
|
|
</div>
|
|
) : marketDetail ? (
|
|
<>
|
|
<div className="flex items-start justify-between gap-3">
|
|
<div className="flex items-start gap-3 min-w-0">
|
|
<div className="h-12 w-12 overflow-hidden rounded-lg border bg-muted/40 shrink-0">
|
|
{marketDetail.icon_url ? (
|
|
// eslint-disable-next-line @next/next/no-img-element
|
|
<img
|
|
src={marketDetail.icon_url}
|
|
alt={marketDetail.name}
|
|
className="h-full w-full object-cover"
|
|
/>
|
|
) : (
|
|
<div className="h-full w-full flex items-center justify-center text-xs text-muted-foreground">
|
|
MCP
|
|
</div>
|
|
)}
|
|
</div>
|
|
<div className="min-w-0">
|
|
<h2 className="text-base font-semibold break-all">
|
|
{marketDetail.name}
|
|
</h2>
|
|
<p className="text-xs text-muted-foreground break-all mt-1">
|
|
{marketDetail.server_id}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<Button onClick={openInstallDialog}>
|
|
{t("actions.install")}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="flex flex-wrap gap-1.5">
|
|
{marketDetail.verified ? (
|
|
<Badge>{t("badges.verified")}</Badge>
|
|
) : null}
|
|
{marketDetail.remote ? (
|
|
<Badge variant="secondary">{t("badges.remote")}</Badge>
|
|
) : null}
|
|
{marketDetail.homepage ? (
|
|
<Badge variant="outline">{t("badges.hasHomepage")}</Badge>
|
|
) : null}
|
|
{marketDetail.protocols.map((protocol) => (
|
|
<Badge key={`detail-${protocol}`} variant="secondary">
|
|
{protocolBadgeLabel(protocol, mcpT)}
|
|
</Badge>
|
|
))}
|
|
{marketDetail.latest_version ? (
|
|
<Badge variant="outline">
|
|
v{marketDetail.latest_version}
|
|
</Badge>
|
|
) : null}
|
|
{typeof marketDetail.downloads === "number" ? (
|
|
<Badge variant="outline">
|
|
{t("badges.uses", { count: marketDetail.downloads })}
|
|
</Badge>
|
|
) : null}
|
|
</div>
|
|
|
|
<p className="text-sm text-muted-foreground leading-6">
|
|
{marketDetail.description}
|
|
</p>
|
|
|
|
{marketDetail.homepage ? (
|
|
<a
|
|
href={marketDetail.homepage}
|
|
target="_blank"
|
|
rel="noreferrer"
|
|
className="text-xs text-primary underline break-all"
|
|
>
|
|
{marketDetail.homepage}
|
|
</a>
|
|
) : null}
|
|
|
|
<div className="grid gap-2 text-xs text-muted-foreground sm:grid-cols-2">
|
|
{marketDetail.owner ? (
|
|
<div className="inline-flex items-center gap-1.5">
|
|
<ShieldCheck className="h-3.5 w-3.5" />
|
|
{t("market.owner", { owner: marketDetail.owner })}
|
|
</div>
|
|
) : null}
|
|
{marketDetail.namespace ? (
|
|
<div className="inline-flex items-center gap-1.5">
|
|
<TerminalSquare className="h-3.5 w-3.5" />
|
|
{t("market.namespace", {
|
|
namespace: marketDetail.namespace,
|
|
})}
|
|
</div>
|
|
) : null}
|
|
{marketDetail.is_deployed != null ? (
|
|
<div className="inline-flex items-center gap-1.5">
|
|
<Globe className="h-3.5 w-3.5" />
|
|
{marketDetail.is_deployed
|
|
? t("badges.deployed")
|
|
: t("badges.notDeployed")}
|
|
</div>
|
|
) : null}
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("market.defaultInstallProtocol")}
|
|
</div>
|
|
<Select
|
|
value={selectedInstallOption?.id ?? ""}
|
|
onValueChange={switchInstallOption}
|
|
>
|
|
<SelectTrigger>
|
|
<SelectValue
|
|
placeholder={t("installDialog.selectProtocol")}
|
|
/>
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{marketDetail.install_options.map((option) => (
|
|
<SelectItem key={option.id} value={option.id}>
|
|
{protocolBadgeLabel(option.protocol, mcpT)} ·{" "}
|
|
{option.label}
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
<div className="text-[11px] text-muted-foreground">
|
|
{t("market.currentOptionParameterCount", {
|
|
count: selectedInstallOption?.parameters.length ?? 0,
|
|
})}
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<div className="text-xs text-muted-foreground">
|
|
{t("market.installConfigDescription")}
|
|
</div>
|
|
<Textarea
|
|
value={marketSpecText}
|
|
onChange={(event) => {
|
|
setMarketSpecText(event.target.value)
|
|
setMarketSpecDirty(true)
|
|
}}
|
|
className="min-h-[360px] font-mono text-xs"
|
|
/>
|
|
</div>
|
|
</>
|
|
) : (
|
|
<div className="rounded-md border border-dashed p-3 text-xs text-muted-foreground">
|
|
{t("market.selectLeftToView")}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : null}
|
|
|
|
{!selection ? (
|
|
<div className="h-full flex items-center justify-center text-sm text-muted-foreground">
|
|
{t("selectLeftMcp")}
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
</div>
|
|
</>
|
|
)
|
|
}
|