mirror of
https://github.com/HKUDS/nanobot.git
synced 2026-08-14 16:19:17 +03:00
1506 lines
55 KiB
TypeScript
1506 lines
55 KiB
TypeScript
import {
|
|
forwardRef,
|
|
useId,
|
|
useMemo,
|
|
useState,
|
|
type ComponentPropsWithoutRef,
|
|
type Dispatch,
|
|
type ReactNode,
|
|
type SetStateAction,
|
|
} from "react";
|
|
import {
|
|
Check,
|
|
ChevronDown,
|
|
ChevronRight,
|
|
Clipboard,
|
|
Database,
|
|
ExternalLink,
|
|
Loader2,
|
|
PauseCircle,
|
|
PlayCircle,
|
|
Plus,
|
|
RotateCcw,
|
|
Search,
|
|
Server,
|
|
SlidersHorizontal,
|
|
TriangleAlert,
|
|
Trash2,
|
|
} from "lucide-react";
|
|
import { useTranslation } from "react-i18next";
|
|
|
|
import {
|
|
DismissibleStatusMessage,
|
|
RestartRequiredNotice,
|
|
SETTINGS_SEARCH_INPUT_CLASS,
|
|
SettingsSectionTitle,
|
|
} from "@/components/settings/shared/SettingsControls";
|
|
import {
|
|
McpManagementDialog,
|
|
type McpManagementTab,
|
|
} from "@/components/settings/system/McpManagementDialog";
|
|
import { Button } from "@/components/ui/button";
|
|
import {
|
|
DropdownMenu,
|
|
DropdownMenuContent,
|
|
DropdownMenuItem,
|
|
DropdownMenuTrigger,
|
|
} from "@/components/ui/dropdown-menu";
|
|
import { Input } from "@/components/ui/input";
|
|
import { SegmentedControl } from "@/components/ui/segmented-control";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { useLogoFallback } from "@/hooks/useLogoFallback";
|
|
import { isGenericRepositoryLogoUrl, logoFallbackUrls } from "@/lib/provider-brand";
|
|
import type {
|
|
CliAppInfo,
|
|
CliAppsPayload,
|
|
McpOAuthFlowPayload,
|
|
McpPresetInfo,
|
|
McpPresetsPayload,
|
|
} from "@/lib/types";
|
|
import { cn } from "@/lib/utils";
|
|
|
|
export type AppsKindFilter = "ready" | "cli" | "mcp";
|
|
type AppsCatalogItem =
|
|
| { id: string; kind: "cli"; app: CliAppInfo }
|
|
| { id: string; kind: "mcp"; preset: McpPresetInfo };
|
|
type CustomMcpTransport = "stdio" | "streamableHttp" | "sse";
|
|
type CustomMcpAuth = "none" | "oauth" | "headers";
|
|
export const CLI_APPS_REFRESH_RETRY_MS = 2_000;
|
|
export const CLI_APPS_REFRESH_MAX_RETRIES = 30;
|
|
|
|
export interface CustomMcpForm {
|
|
name: string;
|
|
transport: CustomMcpTransport;
|
|
auth: CustomMcpAuth;
|
|
command: string;
|
|
args: string;
|
|
url: string;
|
|
env: string;
|
|
headers: string;
|
|
toolTimeout: string;
|
|
}
|
|
|
|
export const DEFAULT_CUSTOM_MCP_FORM: CustomMcpForm = {
|
|
name: "",
|
|
transport: "stdio",
|
|
auth: "none",
|
|
command: "",
|
|
args: "",
|
|
url: "",
|
|
env: "",
|
|
headers: "",
|
|
toolTimeout: "30",
|
|
};
|
|
|
|
export function AppsCatalogSettings({
|
|
cliApps,
|
|
mcpPresets,
|
|
cliAppsLoading,
|
|
mcpPresetsLoading,
|
|
query,
|
|
filter,
|
|
cliActionKey,
|
|
mcpActionKey,
|
|
mcpOAuthFlow,
|
|
mcpOAuthPopupBlocked,
|
|
mcpOAuthCallbackUrl,
|
|
mcpOAuthCompleting,
|
|
mcpOAuthCallbackError,
|
|
cliMessage,
|
|
cliError,
|
|
cliFocusName,
|
|
mcpMessage,
|
|
mcpError,
|
|
mcpFieldValues,
|
|
customMcpForm,
|
|
mcpConfigImport,
|
|
showBrandLogos,
|
|
requiresRestartPending,
|
|
onQueryChange,
|
|
onFilterChange,
|
|
onCliAction,
|
|
onMcpAction,
|
|
onMcpOAuthConnect,
|
|
onMcpOAuthCancel,
|
|
onMcpOAuthOpen,
|
|
onMcpOAuthCallbackUrlChange,
|
|
onMcpOAuthComplete,
|
|
onDismissStatus,
|
|
onBackToChat,
|
|
onMcpFieldChange,
|
|
onCustomMcpFormChange,
|
|
onMcpConfigImportChange,
|
|
onSaveCustomMcp,
|
|
onImportMcpConfig,
|
|
onMcpToolsChange,
|
|
onRestart,
|
|
isRestarting,
|
|
}: {
|
|
cliApps: CliAppsPayload | null;
|
|
mcpPresets: McpPresetsPayload | null;
|
|
cliAppsLoading: boolean;
|
|
mcpPresetsLoading: boolean;
|
|
query: string;
|
|
filter: AppsKindFilter;
|
|
cliActionKey: string | null;
|
|
mcpActionKey: string | null;
|
|
mcpOAuthFlow: McpOAuthFlowPayload | null;
|
|
mcpOAuthPopupBlocked: boolean;
|
|
mcpOAuthCallbackUrl: string;
|
|
mcpOAuthCompleting: boolean;
|
|
mcpOAuthCallbackError: string | null;
|
|
cliMessage: string | null;
|
|
cliError: string | null;
|
|
cliFocusName: string | null;
|
|
mcpMessage: string | null;
|
|
mcpError: string | null;
|
|
mcpFieldValues: Record<string, Record<string, string>>;
|
|
customMcpForm: CustomMcpForm;
|
|
mcpConfigImport: string;
|
|
showBrandLogos: boolean;
|
|
requiresRestartPending: boolean;
|
|
onQueryChange: (value: string) => void;
|
|
onFilterChange: (value: AppsKindFilter) => void;
|
|
onCliAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
|
onMcpAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => void;
|
|
onMcpOAuthConnect: (name: string, reset?: boolean) => void;
|
|
onMcpOAuthCancel: () => void;
|
|
onMcpOAuthOpen: () => void;
|
|
onMcpOAuthCallbackUrlChange: (value: string) => void;
|
|
onMcpOAuthComplete: () => void;
|
|
onDismissStatus: () => void;
|
|
onBackToChat: () => void;
|
|
onMcpFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
|
onCustomMcpFormChange: Dispatch<SetStateAction<CustomMcpForm>>;
|
|
onMcpConfigImportChange: (value: string) => void;
|
|
onSaveCustomMcp: () => void;
|
|
onImportMcpConfig: () => void;
|
|
onMcpToolsChange: (name: string, enabledTools: string[]) => void;
|
|
onRestart?: () => void;
|
|
isRestarting?: boolean;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const filterOptions = [
|
|
{ value: "ready", label: tx("settings.apps.filterAll", "Ready") },
|
|
{ value: "cli", label: tx("settings.apps.filterCli", "Apps") },
|
|
{ value: "mcp", label: tx("settings.apps.filterMcp", "MCP") },
|
|
];
|
|
const normalizedQuery = query.trim().toLowerCase();
|
|
const items: AppsCatalogItem[] = [
|
|
...(cliApps?.apps ?? []).map((app) => ({ id: `cli:${app.name}`, kind: "cli" as const, app })),
|
|
...(mcpPresets?.presets ?? []).map((preset) => ({
|
|
id: `mcp:${preset.name}`,
|
|
kind: "mcp" as const,
|
|
preset,
|
|
})),
|
|
]
|
|
.filter((item) => {
|
|
if (normalizedQuery) return appsSearchText(item).includes(normalizedQuery);
|
|
if (filter === "ready") return appsReady(item);
|
|
if (filter === "cli") {
|
|
return item.kind === "cli" || item.preset.source === "agent-plugin";
|
|
}
|
|
return item.kind === "mcp" && item.preset.source !== "agent-plugin";
|
|
})
|
|
.sort((left, right) => {
|
|
const rank = Number(!appsReady(left)) - Number(!appsReady(right));
|
|
return rank || appsTitle(left).localeCompare(appsTitle(right));
|
|
});
|
|
const focusedApp = cliFocusName
|
|
? (cliApps?.apps ?? []).find((app) => app.name === cliFocusName && app.installed)
|
|
: null;
|
|
const loading =
|
|
(cliAppsLoading || mcpPresetsLoading) &&
|
|
!cliApps &&
|
|
!mcpPresets;
|
|
const cliAppCount = cliApps?.apps.length ?? 0;
|
|
const emptyTitle = normalizedQuery
|
|
? tx("settings.apps.empty", "No tools match your search.")
|
|
: filter === "cli"
|
|
? tx("settings.apps.emptyApps", "No apps available.")
|
|
: filter === "mcp"
|
|
? tx("settings.apps.emptyIntegrations", "No MCP tools available.")
|
|
: tx("settings.apps.emptyReady", "No tools are ready yet.");
|
|
const emptyBrowseTarget: AppsKindFilter | null = normalizedQuery
|
|
? null
|
|
: filter === "cli"
|
|
? "mcp"
|
|
: filter === "mcp"
|
|
? (cliAppCount ? "cli" : null)
|
|
: cliAppCount
|
|
? "cli"
|
|
: "mcp";
|
|
const statusMessage =
|
|
cliError ||
|
|
mcpError ||
|
|
(!focusedApp ? cliMessage || mcpMessage : null);
|
|
const statusIsError = Boolean(cliError || mcpError);
|
|
const oauthStatusAnnouncement = mcpOAuthFlow
|
|
? mcpOAuthStatusText(
|
|
mcpOAuthFlow.status,
|
|
mcpOAuthPopupBlocked,
|
|
tx,
|
|
mcpOAuthFlow.completion_input,
|
|
)
|
|
: "";
|
|
return (
|
|
<div className="space-y-7">
|
|
<div role="status" className="sr-only">{oauthStatusAnnouncement}</div>
|
|
<section className="space-y-4">
|
|
<div className="flex flex-col gap-3 lg:flex-row lg:items-center">
|
|
<div className="relative flex-1">
|
|
<Search className="pointer-events-none absolute left-4 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" aria-hidden />
|
|
<Input
|
|
value={query}
|
|
onChange={(event) => onQueryChange(event.target.value)}
|
|
placeholder={tx("settings.apps.searchPlaceholder", "Search Apps")}
|
|
className={cn(
|
|
"h-12 rounded-[14px] pl-11 text-[15px]",
|
|
SETTINGS_SEARCH_INPUT_CLASS,
|
|
)}
|
|
/>
|
|
</div>
|
|
<SegmentedControl
|
|
value={filter}
|
|
options={filterOptions}
|
|
onChange={(value) => onFilterChange(value as AppsKindFilter)}
|
|
/>
|
|
</div>
|
|
</section>
|
|
|
|
{statusMessage ? (
|
|
<DismissibleStatusMessage
|
|
message={statusMessage}
|
|
isError={statusIsError}
|
|
onDismiss={onDismissStatus}
|
|
/>
|
|
) : null}
|
|
|
|
{focusedApp ? (
|
|
<CliAppReadyPanel app={focusedApp} showBrandLogos={showBrandLogos} onBackToChat={onBackToChat} />
|
|
) : null}
|
|
|
|
{requiresRestartPending ? (
|
|
<RestartRequiredNotice
|
|
message={tx("settings.apps.restartRequired", "Restart nanobot to apply updated apps and MCP tools.")}
|
|
onRestart={onRestart}
|
|
isRestarting={isRestarting}
|
|
/>
|
|
) : null}
|
|
|
|
<section className="rounded-[22px] bg-settings-surface px-3 py-3 sm:px-4">
|
|
<div className="flex items-center justify-between border-b border-border/45 pb-3">
|
|
<SettingsSectionTitle>
|
|
{filter === "mcp"
|
|
? tx("settings.apps.mcpTools", "MCP tools")
|
|
: tx("settings.apps.featured", "Tools")}
|
|
</SettingsSectionTitle>
|
|
<span className="rounded-full bg-muted px-2.5 py-1 text-[12px] font-medium text-muted-foreground">
|
|
{items.length}
|
|
</span>
|
|
</div>
|
|
{loading ? (
|
|
<div className="flex h-36 items-center justify-center text-sm text-muted-foreground">
|
|
<Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden />
|
|
{tx("settings.apps.loading", "Loading Apps...")}
|
|
</div>
|
|
) : items.length ? (
|
|
<div className="grid grid-cols-1 gap-x-10 gap-y-1 py-3 xl:grid-cols-2">
|
|
{items.map((item) =>
|
|
item.kind === "cli" ? (
|
|
<CliAppsCatalogRow
|
|
key={item.id}
|
|
app={item.app}
|
|
actionKey={cliActionKey}
|
|
showBrandLogos={showBrandLogos}
|
|
onAction={onCliAction}
|
|
/>
|
|
) : (
|
|
<McpAppsCatalogRow
|
|
key={item.id}
|
|
preset={item.preset}
|
|
values={mcpFieldValues[item.preset.name] ?? {}}
|
|
actionKey={mcpActionKey}
|
|
oauthFlow={mcpOAuthFlow?.name === item.preset.name ? mcpOAuthFlow : null}
|
|
oauthPopupBlocked={mcpOAuthPopupBlocked}
|
|
oauthCallbackUrl={mcpOAuthCallbackUrl}
|
|
oauthCompleting={mcpOAuthCompleting}
|
|
oauthCallbackError={mcpOAuthCallbackError}
|
|
showBrandLogos={showBrandLogos}
|
|
showTypeBadge={filter !== "mcp"}
|
|
onFieldChange={onMcpFieldChange}
|
|
onAction={onMcpAction}
|
|
onOAuthConnect={onMcpOAuthConnect}
|
|
onOAuthCancel={onMcpOAuthCancel}
|
|
onOAuthOpen={onMcpOAuthOpen}
|
|
onOAuthCallbackUrlChange={onMcpOAuthCallbackUrlChange}
|
|
onOAuthComplete={onMcpOAuthComplete}
|
|
onToolsChange={onMcpToolsChange}
|
|
/>
|
|
),
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="px-3 py-12 text-center text-sm text-muted-foreground">
|
|
<p>{emptyTitle}</p>
|
|
{normalizedQuery ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="mt-4 rounded-full"
|
|
onClick={() => onQueryChange("")}
|
|
>
|
|
{tx("settings.apps.clearSearch", "Clear search")}
|
|
</Button>
|
|
) : emptyBrowseTarget ? (
|
|
<Button
|
|
type="button"
|
|
variant="outline"
|
|
className="mt-4 rounded-full"
|
|
onClick={() => onFilterChange(emptyBrowseTarget)}
|
|
>
|
|
{emptyBrowseTarget === "cli"
|
|
? tx("settings.apps.browseApps", "Browse apps")
|
|
: tx("settings.apps.browseIntegrations", "Browse MCP tools")}
|
|
</Button>
|
|
) : (
|
|
<p className="mx-auto mt-2 max-w-[28rem] text-[12px] leading-5">
|
|
{tx(
|
|
"settings.apps.emptyIntegrationsHint",
|
|
"Add a custom MCP server below.",
|
|
)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{filter === "mcp" ? (
|
|
<McpCustomServerPanel
|
|
form={customMcpForm}
|
|
configImport={mcpConfigImport}
|
|
actionKey={mcpActionKey}
|
|
onFormChange={onCustomMcpFormChange}
|
|
onConfigImportChange={onMcpConfigImportChange}
|
|
onSave={onSaveCustomMcp}
|
|
onImportConfig={onImportMcpConfig}
|
|
/>
|
|
) : null}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CliAppsCatalogRow({
|
|
app,
|
|
actionKey,
|
|
showBrandLogos,
|
|
onAction,
|
|
}: {
|
|
app: CliAppInfo;
|
|
actionKey: string | null;
|
|
showBrandLogos: boolean;
|
|
onAction: (action: "install" | "update" | "uninstall" | "test", name: string) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const installBusy = actionKey === `install:${app.name}`;
|
|
const updateBusy = actionKey === `update:${app.name}`;
|
|
const uninstallBusy = actionKey === `uninstall:${app.name}`;
|
|
const testBusy = actionKey === `test:${app.name}`;
|
|
const busy = installBusy || updateBusy || uninstallBusy || testBusy;
|
|
const description = app.description || app.requires || app.entry_point || app.name;
|
|
|
|
return (
|
|
<article className="apps-catalog-row group flex min-w-0 items-center gap-3 rounded-[14px] px-3 py-3 transition-colors hover:bg-muted/45">
|
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{app.display_name}</h3>
|
|
<AppsTypeBadge>{tx("settings.apps.cliLabel", "App")}</AppsTypeBadge>
|
|
</div>
|
|
<p className="mt-0.5 truncate text-[12.5px] leading-5 text-muted-foreground">{description}</p>
|
|
</div>
|
|
<div className="flex shrink-0 items-center gap-1">
|
|
{app.installed ? (
|
|
<>
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.cliApps.statusInstalled", "CLI installed")}
|
|
busy={testBusy || updateBusy}
|
|
disabled={busy}
|
|
tone="installed"
|
|
>
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("test", app.name)}>
|
|
<PlayCircle aria-hidden />
|
|
{tx("settings.cliApps.test", "Test CLI")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem disabled={busy} onClick={() => onAction("update", app.name)}>
|
|
<RotateCcw aria-hidden />
|
|
{tx("settings.cliApps.update", "Update CLI")}
|
|
</DropdownMenuItem>
|
|
<DropdownMenuItem
|
|
tone="destructive"
|
|
disabled={busy}
|
|
onClick={() => onAction("uninstall", app.name)}
|
|
>
|
|
<Trash2 aria-hidden />
|
|
{tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.cliApps.uninstall", "Uninstall CLI")}
|
|
busy={uninstallBusy}
|
|
disabled={busy && !uninstallBusy}
|
|
tone="danger"
|
|
onClick={() => onAction("uninstall", app.name)}
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
</>
|
|
) : app.install_supported ? (
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.cliApps.install", "Install CLI")}
|
|
busy={installBusy}
|
|
onClick={() => onAction("install", app.name)}
|
|
>
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
) : (
|
|
<AppsActionButton ariaLabel={tx("settings.cliApps.unavailable", "Unavailable")} disabled>
|
|
<Plus className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
)}
|
|
</div>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function McpAppsCatalogRow({
|
|
preset,
|
|
values,
|
|
actionKey,
|
|
oauthFlow,
|
|
oauthPopupBlocked,
|
|
oauthCallbackUrl,
|
|
oauthCompleting,
|
|
oauthCallbackError,
|
|
showBrandLogos,
|
|
showTypeBadge,
|
|
onFieldChange,
|
|
onAction,
|
|
onOAuthConnect,
|
|
onOAuthCancel,
|
|
onOAuthOpen,
|
|
onOAuthCallbackUrlChange,
|
|
onOAuthComplete,
|
|
onToolsChange,
|
|
}: {
|
|
preset: McpPresetInfo;
|
|
values: Record<string, string>;
|
|
actionKey: string | null;
|
|
oauthFlow: McpOAuthFlowPayload | null;
|
|
oauthPopupBlocked: boolean;
|
|
oauthCallbackUrl: string;
|
|
oauthCompleting: boolean;
|
|
oauthCallbackError: string | null;
|
|
showBrandLogos: boolean;
|
|
showTypeBadge: boolean;
|
|
onFieldChange: (presetName: string, fieldName: string, value: string) => void;
|
|
onAction: (action: "enable" | "disable" | "remove" | "test" | "reconnect", name: string, values?: Record<string, string>) => void;
|
|
onOAuthConnect: (name: string, reset?: boolean) => void;
|
|
onOAuthCancel: () => void;
|
|
onOAuthOpen: () => void;
|
|
onOAuthCallbackUrlChange: (value: string) => void;
|
|
onOAuthComplete: () => void;
|
|
onToolsChange: (name: string, enabledTools: string[]) => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const [managementOpen, setManagementOpen] = useState(false);
|
|
const [managementTab, setManagementTab] = useState<McpManagementTab>("overview");
|
|
const enableBusy = actionKey === `enable:${preset.name}`;
|
|
const disableBusy = actionKey === `disable:${preset.name}`;
|
|
const removeBusy = actionKey === `remove:${preset.name}`;
|
|
const testBusy = actionKey === `test:${preset.name}`;
|
|
const reconnectBusy = actionKey === `reconnect:${preset.name}`;
|
|
const toolsBusy = actionKey === `tools:${preset.name}`;
|
|
const oauthBusy = actionKey === `oauth:${preset.name}`;
|
|
const anotherOAuthBusy = Boolean(actionKey?.startsWith("oauth:")) && !oauthBusy;
|
|
const busy = enableBusy || disableBusy || removeBusy || testBusy || reconnectBusy || toolsBusy || oauthBusy;
|
|
const agentPlugin = preset.source === "agent-plugin";
|
|
const toggleable = preset.enabled !== undefined;
|
|
const isOAuth = preset.auth === "oauth";
|
|
const missingFields = preset.required_fields.filter((field) => field.required && !field.configured);
|
|
const hasFields = preset.required_fields.length > 0;
|
|
const needsSetupInput = missingFields.length > 0;
|
|
const configuredInstalled = preset.installed && preset.configured;
|
|
const readyInstalled = preset.enabled ?? configuredInstalled;
|
|
const runtimeConnected = !toggleable && preset.runtime_status === "connected";
|
|
const runtimeConnecting = !toggleable && preset.runtime_status === "connecting";
|
|
const runtimeFailed = !toggleable && preset.runtime_status === "failed";
|
|
const statusLabel = toggleable
|
|
? tx("settings.nanobotFeatures.enabled", "Enabled")
|
|
: runtimeConnected
|
|
? tx("connection.open", "Connected")
|
|
: mcpPresetStatusLabel(preset.status, tx);
|
|
const failureLabel = tx("settings.mcp.connectionFailed", "Connection failed.");
|
|
const failureStatusLabel = failureLabel.replace(/[.!。!]+$/u, "");
|
|
const description = tx(
|
|
`settings.mcp.presetDescriptions.${preset.name}`,
|
|
preset.description || preset.note || preset.name,
|
|
);
|
|
const detail = agentPlugin && preset.requires
|
|
? `${description} · ${preset.requires}`
|
|
: description || preset.requires;
|
|
const manualCallback =
|
|
oauthFlow?.completion_input === "callback_url" && Boolean(oauthFlow.authorization_url);
|
|
const callbackInputId = `mcp-oauth-callback-${preset.name}`;
|
|
const callbackHelpId = `${callbackInputId}-help`;
|
|
const callbackErrorId = `${callbackInputId}-error`;
|
|
|
|
const enableOrOpenSetup = () => {
|
|
if (isOAuth) {
|
|
onOAuthConnect(preset.name);
|
|
return;
|
|
}
|
|
if (needsSetupInput || (preset.installed && !preset.configured && hasFields)) {
|
|
setManagementTab("connection");
|
|
setManagementOpen(true);
|
|
return;
|
|
}
|
|
onAction("enable", preset.name, values);
|
|
};
|
|
const openManagement = (tab: McpManagementTab = "overview") => {
|
|
setManagementTab(tab);
|
|
setManagementOpen(true);
|
|
};
|
|
|
|
return (
|
|
<article className="min-w-0 rounded-[14px] transition-colors hover:bg-muted/45">
|
|
<div className="group flex min-w-0 flex-wrap items-center gap-x-3 gap-y-2 px-3 py-3">
|
|
<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} />
|
|
<div className="min-w-[8rem] flex-[1_1_8rem]">
|
|
<div className="flex min-w-0 items-baseline gap-2">
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">{preset.display_name}</h3>
|
|
{showTypeBadge ? (
|
|
<AppsTypeBadge>
|
|
{agentPlugin
|
|
? tx("settings.apps.filterPlugins", "Plugins")
|
|
: tx("settings.apps.mcpLabel", "MCP")}
|
|
</AppsTypeBadge>
|
|
) : null}
|
|
</div>
|
|
<p
|
|
className={cn(
|
|
"mt-0.5 flex min-w-0 items-center gap-1.5 text-[12.5px] leading-5 text-muted-foreground",
|
|
runtimeFailed && configuredInstalled && "font-medium text-destructive",
|
|
)}
|
|
>
|
|
{runtimeFailed && configuredInstalled ? (
|
|
<TriangleAlert className="h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
) : null}
|
|
<span className="truncate">
|
|
{runtimeFailed && configuredInstalled ? failureLabel : detail}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
<div className="ml-auto flex shrink-0 items-center gap-1">
|
|
{oauthFlow ? (
|
|
<>
|
|
<AppsActionButton
|
|
ariaLabel={t("settings.mcp.connectingAccount", {
|
|
name: preset.display_name,
|
|
defaultValue: "Connecting {{name}}",
|
|
})}
|
|
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
|
|
busy
|
|
/>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.actions.cancel", "Cancel")}
|
|
visibleLabel={tx("settings.actions.cancel", "Cancel")}
|
|
tone="danger"
|
|
onClick={onOAuthCancel}
|
|
/>
|
|
</>
|
|
) : runtimeConnecting && configuredInstalled ? (
|
|
<>
|
|
<AppsActionButton
|
|
ariaLabel={`${preset.display_name}: ${tx("settings.mcp.connectingLabel", "Connecting…")}`}
|
|
visibleLabel={tx("settings.mcp.connectingLabel", "Connecting…")}
|
|
busy
|
|
/>
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.mcp.remove", "Remove")}
|
|
busy={removeBusy}
|
|
disabled={busy && !removeBusy}
|
|
tone="danger"
|
|
onClick={() => onAction("remove", preset.name)}
|
|
>
|
|
<Trash2 className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
</>
|
|
) : runtimeFailed && configuredInstalled ? (
|
|
<AppsActionButton
|
|
ariaLabel={t("settings.mcp.manageTitle", {
|
|
name: preset.display_name,
|
|
defaultValue: "Manage {{name}}",
|
|
})}
|
|
visibleLabel={tx("settings.mcp.fixConnection", "Fix connection")}
|
|
disabled={anotherOAuthBusy}
|
|
onClick={() => openManagement("connection")}
|
|
>
|
|
<SlidersHorizontal className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
) : readyInstalled ? (
|
|
toggleable ? (
|
|
<DropdownMenu>
|
|
<DropdownMenuTrigger asChild>
|
|
<AppsActionButton
|
|
ariaLabel={`${preset.display_name}: ${statusLabel}`}
|
|
visibleLabel={statusLabel}
|
|
busy={disableBusy}
|
|
disabled={busy}
|
|
tone="installed"
|
|
>
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
</DropdownMenuTrigger>
|
|
<DropdownMenuContent align="end">
|
|
<DropdownMenuItem
|
|
disabled={busy}
|
|
onClick={() => onAction("disable", preset.name)}
|
|
>
|
|
<PauseCircle aria-hidden />
|
|
{tx("settings.nanobotFeatures.disable", "Disable")}
|
|
</DropdownMenuItem>
|
|
</DropdownMenuContent>
|
|
</DropdownMenu>
|
|
) : (
|
|
<AppsActionButton
|
|
ariaLabel={t("settings.mcp.manageTitle", {
|
|
name: preset.display_name,
|
|
defaultValue: "Manage {{name}}",
|
|
})}
|
|
visibleLabel={tx("settings.mcp.manage", "Manage")}
|
|
busy={testBusy || toolsBusy || removeBusy || reconnectBusy}
|
|
disabled={busy}
|
|
tone={runtimeConnected ? "installed" : "default"}
|
|
onClick={() => openManagement("overview")}
|
|
>
|
|
<Check className="h-4 w-4" aria-hidden />
|
|
</AppsActionButton>
|
|
)
|
|
) : preset.enabled === false ? (
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.nanobotFeatures.enable", "Enable")}
|
|
visibleLabel={tx("settings.nanobotFeatures.enable", "Enable")}
|
|
busy={enableBusy}
|
|
onClick={() => onAction("enable", preset.name, values)}
|
|
/>
|
|
) : isOAuth && preset.install_supported ? (
|
|
<AppsActionButton
|
|
ariaLabel={t("settings.mcp.connectTitle", {
|
|
name: preset.display_name,
|
|
defaultValue: "Connect {{name}}",
|
|
})}
|
|
visibleLabel={tx("settings.mcp.setup", "Connect")}
|
|
busy={oauthBusy}
|
|
disabled={anotherOAuthBusy}
|
|
onClick={() => onOAuthConnect(preset.name)}
|
|
/>
|
|
) : preset.installed && !preset.configured ? (
|
|
<AppsActionButton
|
|
ariaLabel={hasFields ? tx("settings.mcp.configure", "Configure") : tx("settings.mcp.enable", "Enable")}
|
|
visibleLabel={hasFields ? tx("settings.mcp.configure", "Connect") : tx("settings.mcp.enable", "Enable")}
|
|
busy={enableBusy}
|
|
onClick={() => {
|
|
if (hasFields) openManagement("connection");
|
|
else onAction("enable", preset.name, values);
|
|
}}
|
|
/>
|
|
) : preset.install_supported ? (
|
|
<AppsActionButton
|
|
ariaLabel={t("settings.mcp.connectTitle", {
|
|
name: preset.display_name,
|
|
defaultValue: "Connect {{name}}",
|
|
})}
|
|
visibleLabel={tx("settings.mcp.setup", "Connect")}
|
|
busy={enableBusy}
|
|
onClick={enableOrOpenSetup}
|
|
/>
|
|
) : (
|
|
<AppsActionButton
|
|
ariaLabel={tx("settings.mcp.comingSoon", "Coming soon")}
|
|
visibleLabel={tx("settings.mcp.comingSoon", "Coming soon")}
|
|
disabled
|
|
/>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{manualCallback ? (
|
|
<form
|
|
className="mx-3 mb-3 min-w-0 space-y-3 rounded-[14px] bg-background/55 p-3"
|
|
onSubmit={(event) => {
|
|
event.preventDefault();
|
|
onOAuthComplete();
|
|
}}
|
|
>
|
|
<div className="flex min-w-0 items-start gap-2.5 text-[12.5px] text-muted-foreground">
|
|
<Clipboard className="mt-0.5 h-3.5 w-3.5 shrink-0" aria-hidden />
|
|
<div className="min-w-0 space-y-1">
|
|
<p className="font-medium text-foreground">
|
|
{t("settings.oauth.pasteCallbackToContinue")}
|
|
</p>
|
|
<p id={callbackHelpId} className="leading-5">
|
|
{tx(
|
|
"settings.mcp.manualCallbackHelp",
|
|
"After approving access, the localhost page will not load. Copy its full URL from the address bar and paste it here.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="min-w-0 space-y-2">
|
|
<label
|
|
htmlFor={callbackInputId}
|
|
className="block text-xs font-medium text-foreground"
|
|
>
|
|
{t("settings.oauth.callbackUrl")}
|
|
</label>
|
|
<Textarea
|
|
id={callbackInputId}
|
|
value={oauthCallbackUrl}
|
|
onChange={(event) => onOAuthCallbackUrlChange(event.target.value)}
|
|
placeholder={t("settings.oauth.callbackUrlPlaceholder")}
|
|
autoComplete="off"
|
|
spellCheck={false}
|
|
required
|
|
aria-invalid={Boolean(oauthCallbackError)}
|
|
aria-describedby={
|
|
oauthCallbackError
|
|
? `${callbackHelpId} ${callbackErrorId}`
|
|
: callbackHelpId
|
|
}
|
|
className="min-h-[88px] w-full resize-y break-all font-mono text-[12px] leading-5"
|
|
/>
|
|
{oauthCallbackError ? (
|
|
<p
|
|
id={callbackErrorId}
|
|
role="alert"
|
|
className="text-[12px] leading-5 text-destructive"
|
|
>
|
|
{oauthCallbackError}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
<div className="flex flex-col gap-2 sm:flex-row sm:justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={onOAuthOpen}
|
|
className="h-9 w-full rounded-full px-3 text-[12px] font-semibold sm:w-auto"
|
|
>
|
|
{tx("settings.mcp.continueSignIn", "Continue sign-in")}
|
|
<ExternalLink className="ml-1.5 h-3.5 w-3.5" aria-hidden />
|
|
</Button>
|
|
<Button
|
|
type="submit"
|
|
size="sm"
|
|
disabled={oauthCompleting}
|
|
className="h-9 w-full rounded-full px-3 text-[12px] font-semibold sm:w-auto"
|
|
>
|
|
{oauthCompleting ? (
|
|
<Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden />
|
|
) : null}
|
|
{t("settings.oauth.finishSignIn")}
|
|
</Button>
|
|
</div>
|
|
</form>
|
|
) : oauthFlow && oauthPopupBlocked && oauthFlow.authorization_url ? (
|
|
<div className="mx-3 mb-3 flex flex-col gap-2.5 rounded-[14px] bg-background/55 p-3">
|
|
<div className="flex min-w-0 items-center gap-2.5 text-[12.5px] text-muted-foreground">
|
|
<span>
|
|
{mcpOAuthStatusText(
|
|
oauthFlow.status,
|
|
oauthPopupBlocked,
|
|
tx,
|
|
oauthFlow.completion_input,
|
|
)}
|
|
</span>
|
|
</div>
|
|
<div className="flex shrink-0 items-center justify-end gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="outline"
|
|
onClick={onOAuthOpen}
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
>
|
|
{tx("settings.mcp.continueSignIn", "Continue sign-in")}
|
|
<ExternalLink className="ml-1.5 h-3.5 w-3.5" aria-hidden />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{managementOpen ? (
|
|
<McpManagementDialog
|
|
preset={preset}
|
|
values={values}
|
|
actionKey={actionKey}
|
|
statusLabel={runtimeFailed && configuredInstalled ? failureStatusLabel : statusLabel}
|
|
statusTone={runtimeFailed ? "warning" : configuredInstalled ? "success" : "neutral"}
|
|
tab={managementTab}
|
|
icon={<McpPresetLogo preset={preset} showBrandLogos={showBrandLogos} compact />}
|
|
onTabChange={setManagementTab}
|
|
onOpenChange={setManagementOpen}
|
|
onFieldChange={onFieldChange}
|
|
onAction={onAction}
|
|
onOAuthConnect={onOAuthConnect}
|
|
onToolsChange={onToolsChange}
|
|
/>
|
|
) : null}
|
|
</article>
|
|
);
|
|
}
|
|
|
|
function AppsTypeBadge({ children }: { children: ReactNode }) {
|
|
return (
|
|
<span className="shrink-0 rounded-full bg-muted px-1.5 py-0.5 text-[10px] font-medium leading-none text-muted-foreground">
|
|
{children}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
type AppsActionButtonProps = Omit<
|
|
ComponentPropsWithoutRef<typeof Button>,
|
|
"aria-label" | "children" | "disabled" | "size" | "variant"
|
|
> & {
|
|
ariaLabel: string;
|
|
visibleLabel?: string;
|
|
busy?: boolean;
|
|
disabled?: boolean;
|
|
tone?: "default" | "installed" | "danger";
|
|
children?: ReactNode;
|
|
};
|
|
|
|
export const AppsActionButton = forwardRef<HTMLButtonElement, AppsActionButtonProps>(
|
|
function AppsActionButton({
|
|
ariaLabel,
|
|
visibleLabel,
|
|
busy,
|
|
disabled,
|
|
tone = "default",
|
|
children,
|
|
className,
|
|
...buttonProps
|
|
}, ref) {
|
|
return (
|
|
<Button
|
|
{...buttonProps}
|
|
ref={ref}
|
|
type="button"
|
|
size={visibleLabel ? "sm" : "icon"}
|
|
variant="ghost"
|
|
aria-label={ariaLabel}
|
|
title={ariaLabel}
|
|
disabled={disabled || busy}
|
|
className={cn(
|
|
"rounded-full text-muted-foreground transition-colors",
|
|
visibleLabel
|
|
? "h-8 w-auto gap-1.5 px-3 text-[12px] font-semibold"
|
|
: "h-9 w-9",
|
|
tone === "installed" && "bg-transparent hover:bg-muted/70 hover:text-foreground",
|
|
tone === "danger" && "bg-transparent hover:bg-destructive/10 hover:text-destructive",
|
|
tone === "default" && "bg-muted/70 hover:bg-muted hover:text-foreground",
|
|
className,
|
|
)}
|
|
>
|
|
{busy ? <Loader2 className="h-4 w-4 animate-spin motion-reduce:animate-none" aria-hidden /> : children}
|
|
{visibleLabel ? <span>{visibleLabel}</span> : null}
|
|
</Button>
|
|
);
|
|
},
|
|
);
|
|
|
|
function appsTitle(item: AppsCatalogItem): string {
|
|
return item.kind === "cli" ? item.app.display_name : item.preset.display_name;
|
|
}
|
|
|
|
function appsReady(item: AppsCatalogItem): boolean {
|
|
if (item.kind === "cli") return item.app.installed;
|
|
if (item.preset.enabled !== undefined) return item.preset.enabled;
|
|
return item.preset.installed &&
|
|
item.preset.configured &&
|
|
item.preset.runtime_status === "connected";
|
|
}
|
|
|
|
function appsSearchText(item: AppsCatalogItem): string {
|
|
if (item.kind === "cli") {
|
|
const app = item.app;
|
|
return [
|
|
app.display_name,
|
|
app.name,
|
|
app.category,
|
|
app.description,
|
|
app.requires,
|
|
app.entry_point,
|
|
app.source,
|
|
]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
const preset = item.preset;
|
|
return [
|
|
preset.display_name,
|
|
preset.name,
|
|
preset.category,
|
|
preset.description,
|
|
preset.requires,
|
|
preset.note,
|
|
preset.transport,
|
|
preset.source ?? "",
|
|
]
|
|
.join(" ")
|
|
.toLowerCase();
|
|
}
|
|
|
|
function McpCustomServerPanel({
|
|
form,
|
|
configImport,
|
|
actionKey,
|
|
onFormChange,
|
|
onConfigImportChange,
|
|
onSave,
|
|
onImportConfig,
|
|
}: {
|
|
form: CustomMcpForm;
|
|
configImport: string;
|
|
actionKey: string | null;
|
|
onFormChange: Dispatch<SetStateAction<CustomMcpForm>>;
|
|
onConfigImportChange: (value: string) => void;
|
|
onSave: () => void;
|
|
onImportConfig: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback });
|
|
const oauthHelpId = useId();
|
|
const headersInputId = useId();
|
|
const headersHelpId = useId();
|
|
const [activeMode, setActiveMode] = useState<"custom" | "import" | null>(null);
|
|
const [advancedOpen, setAdvancedOpen] = useState(false);
|
|
const customBusy = actionKey?.startsWith("custom:") ?? false;
|
|
const importBusy = actionKey === "import" || actionKey === "import-cursor";
|
|
const remote = form.transport !== "stdio";
|
|
const canSave = Boolean(form.name.trim()) && (remote ? Boolean(form.url.trim()) : Boolean(form.command.trim()));
|
|
const update = <K extends keyof CustomMcpForm>(key: K, value: CustomMcpForm[K]) => {
|
|
onFormChange((prev) => ({ ...prev, [key]: value }));
|
|
};
|
|
const transports: Array<{ value: CustomMcpTransport; label: string }> = [
|
|
{ value: "stdio", label: "stdio" },
|
|
{ value: "streamableHttp", label: "HTTP" },
|
|
{ value: "sse", label: "SSE" },
|
|
];
|
|
const authenticationOptions: Array<{ value: CustomMcpAuth; label: string }> = [
|
|
{ value: "none", label: tx("settings.mcp.authNone", "None") },
|
|
{ value: "oauth", label: "OAuth" },
|
|
{ value: "headers", label: tx("settings.mcp.authHeaders", "Headers") },
|
|
];
|
|
|
|
return (
|
|
<section className="overflow-hidden rounded-[16px] bg-settings-surface">
|
|
<div className="flex flex-col gap-3 px-3 py-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div className="flex min-w-0 items-center gap-3">
|
|
<span className="grid h-9 w-9 shrink-0 place-items-center rounded-[11px] bg-muted text-muted-foreground">
|
|
<Server className="h-4 w-4" aria-hidden />
|
|
</span>
|
|
<div className="min-w-0">
|
|
<h3 className="text-[13px] font-semibold leading-5 text-foreground">
|
|
{tx("settings.mcp.moreOptions", "Add MCP server")}
|
|
</h3>
|
|
<p className="truncate text-[12px] text-muted-foreground">
|
|
{tx(
|
|
"settings.mcp.moreOptionsSubtitle",
|
|
"Connect a custom MCP server or import an existing configuration.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2 sm:flex sm:shrink-0">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={activeMode === "custom" ? "default" : "outline"}
|
|
onClick={() => setActiveMode((mode) => (mode === "custom" ? null : "custom"))}
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
>
|
|
<Server className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
{tx("settings.mcp.customAction", "Custom")}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant={activeMode === "import" ? "default" : "outline"}
|
|
onClick={() => setActiveMode((mode) => (mode === "import" ? null : "import"))}
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
>
|
|
<Database className="mr-1.5 h-3.5 w-3.5" aria-hidden />
|
|
{tx("settings.mcp.importAction", "Import")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
|
|
{activeMode === "custom" ? (
|
|
<div className="border-t border-border/35 bg-muted/18 px-3 py-3">
|
|
<div className="flex flex-col gap-3 xl:flex-row xl:items-end">
|
|
<label className="min-w-0 flex-1">
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.serverName", "Server name")}
|
|
</span>
|
|
<Input
|
|
value={form.name}
|
|
onChange={(event) => update("name", event.target.value)}
|
|
placeholder="docs"
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
/>
|
|
</label>
|
|
<div className="min-w-[228px]">
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.transport", "Transport")}
|
|
</span>
|
|
<SegmentedControl
|
|
value={form.transport}
|
|
options={transports}
|
|
onChange={(value) => update("transport", value as CustomMcpTransport)}
|
|
/>
|
|
</div>
|
|
{remote ? (
|
|
<label className="min-w-0 flex-[1.4]">
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.serverUrl", "URL")}
|
|
</span>
|
|
<Input
|
|
value={form.url}
|
|
onChange={(event) => update("url", event.target.value)}
|
|
placeholder={form.transport === "sse" ? "https://example.com/sse" : "https://example.com/mcp"}
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
/>
|
|
</label>
|
|
) : (
|
|
<label className="min-w-0 flex-[1.4]">
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.command", "Command")}
|
|
</span>
|
|
<Input
|
|
value={form.command}
|
|
onChange={(event) => update("command", event.target.value)}
|
|
placeholder="npx"
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
/>
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{remote ? (
|
|
<div className="mt-3 grid min-w-0 gap-3 sm:grid-cols-[minmax(0,auto)_minmax(0,1fr)] sm:items-end">
|
|
<fieldset
|
|
className="min-w-0"
|
|
aria-describedby={form.auth === "oauth" ? oauthHelpId : undefined}
|
|
>
|
|
<legend className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.authentication", "Authentication")}
|
|
</legend>
|
|
<SegmentedControl
|
|
value={form.auth}
|
|
options={authenticationOptions}
|
|
onChange={(value) => update("auth", value as CustomMcpAuth)}
|
|
className="w-full sm:w-auto"
|
|
itemClassName="min-w-0 flex-1 sm:flex-none"
|
|
/>
|
|
</fieldset>
|
|
{form.auth === "oauth" ? (
|
|
<p
|
|
id={oauthHelpId}
|
|
className="pb-1 text-[12px] leading-5 text-muted-foreground"
|
|
>
|
|
{tx(
|
|
"settings.mcp.oauthAfterSave",
|
|
"Save the server, then select Connect to sign in.",
|
|
)}
|
|
</p>
|
|
) : null}
|
|
</div>
|
|
) : null}
|
|
|
|
{remote && form.auth === "headers" ? (
|
|
<div className="mt-3 min-w-0">
|
|
<label
|
|
htmlFor={headersInputId}
|
|
className="mb-1 block text-[11.5px] font-medium text-muted-foreground"
|
|
>
|
|
{tx("settings.mcp.headers", "Headers JSON")}
|
|
</label>
|
|
<Textarea
|
|
id={headersInputId}
|
|
aria-describedby={headersHelpId}
|
|
value={form.headers}
|
|
onChange={(event) => update("headers", event.target.value)}
|
|
placeholder={'{"Authorization":"Bearer ..."}'}
|
|
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
/>
|
|
<p
|
|
id={headersHelpId}
|
|
className="mt-1 text-[11.5px] leading-5 text-muted-foreground"
|
|
>
|
|
{tx(
|
|
"settings.mcp.headersHelp",
|
|
"Add the request headers used by this server.",
|
|
)}
|
|
</p>
|
|
</div>
|
|
) : null}
|
|
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={() => setAdvancedOpen((open) => !open)}
|
|
className="mt-2 h-8 rounded-full px-2 text-[12px] font-medium text-muted-foreground hover:text-foreground"
|
|
>
|
|
<ChevronDown
|
|
className={cn("mr-1.5 h-3.5 w-3.5 transition-transform", advancedOpen ? "rotate-180" : "")}
|
|
aria-hidden
|
|
/>
|
|
{advancedOpen
|
|
? tx("settings.mcp.hideAdvanced", "Hide advanced")
|
|
: tx("settings.mcp.advancedOptions", "Advanced options")}
|
|
</Button>
|
|
|
|
{advancedOpen ? (
|
|
<div
|
|
className={cn(
|
|
"mt-2 grid gap-2",
|
|
remote
|
|
? "xl:grid-cols-[minmax(0,1fr)_180px]"
|
|
: "xl:grid-cols-[minmax(0,1.2fr)_minmax(0,1fr)_180px]",
|
|
)}
|
|
>
|
|
{!remote ? (
|
|
<label className="min-w-0">
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.args", "Args JSON")}
|
|
</span>
|
|
<Textarea
|
|
value={form.args}
|
|
onChange={(event) => update("args", event.target.value)}
|
|
placeholder={'["-y", "docs-mcp"]'}
|
|
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
/>
|
|
</label>
|
|
) : null}
|
|
<label className="min-w-0">
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.env", "Env JSON")}
|
|
</span>
|
|
<Textarea
|
|
value={form.env}
|
|
onChange={(event) => update("env", event.target.value)}
|
|
placeholder={'{"API_KEY":"..."}'}
|
|
className="min-h-[68px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
/>
|
|
</label>
|
|
<label className="min-w-0">
|
|
<span className="mb-1 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.timeout", "Tool timeout")}
|
|
</span>
|
|
<Input
|
|
value={form.toolTimeout}
|
|
onChange={(event) => update("toolTimeout", event.target.value)}
|
|
inputMode="numeric"
|
|
className="h-9 rounded-full bg-background/80 text-[12.5px]"
|
|
/>
|
|
</label>
|
|
</div>
|
|
) : null}
|
|
|
|
<div className="mt-3 flex justify-end">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={onSave}
|
|
disabled={!canSave || customBusy}
|
|
className="h-9 w-full rounded-full px-4 text-[12.5px] font-semibold sm:w-auto"
|
|
>
|
|
{customBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
|
|
{tx("settings.mcp.saveCustom", "Save MCP")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
|
|
{activeMode === "import" ? (
|
|
<div className="border-t border-border/35 bg-muted/18 px-3 py-3">
|
|
<div className="flex flex-col gap-2 xl:flex-row xl:items-end">
|
|
<label className="min-w-0 flex-1">
|
|
<span className="mb-1.5 block text-[11.5px] font-medium text-muted-foreground">
|
|
{tx("settings.mcp.configImport", "Import mcp.json")}
|
|
</span>
|
|
<Textarea
|
|
value={configImport}
|
|
onChange={(event) => onConfigImportChange(event.target.value)}
|
|
placeholder={'{"mcpServers":{"docs":{"command":"npx","args":["-y","docs-mcp"]}}}'}
|
|
className="min-h-[84px] resize-y rounded-[12px] bg-background/80 font-mono text-[12px]"
|
|
/>
|
|
</label>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={onImportConfig}
|
|
disabled={!configImport.trim() || importBusy}
|
|
className="h-9 shrink-0 rounded-full px-4 text-[12.5px] font-semibold"
|
|
>
|
|
{importBusy ? <Loader2 className="mr-1.5 h-3.5 w-3.5 animate-spin" aria-hidden /> : <Database className="mr-1.5 h-3.5 w-3.5" aria-hidden />}
|
|
{tx("settings.mcp.importConfig", "Import")}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
) : null}
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function mcpOAuthStatusText(
|
|
status: McpOAuthFlowPayload["status"],
|
|
popupBlocked: boolean,
|
|
tx: (key: string, fallback: string) => string,
|
|
completionInput?: McpOAuthFlowPayload["completion_input"],
|
|
): string {
|
|
switch (status) {
|
|
case "starting":
|
|
return tx("settings.mcp.preparingSignIn", "Preparing secure sign-in...");
|
|
case "authorization_required":
|
|
if (completionInput === "callback_url") {
|
|
return tx(
|
|
"settings.mcp.manualCallbackRequired",
|
|
"Finish signing in, then paste the callback URL into nanobot.",
|
|
);
|
|
}
|
|
return popupBlocked
|
|
? tx("settings.mcp.openSignInToContinue", "Open the sign-in page to continue.")
|
|
: tx("settings.mcp.finishSignInInBrowser", "Finish signing in in the browser window.");
|
|
case "connecting":
|
|
return tx("settings.mcp.finishingConnection", "Finishing connection...");
|
|
case "authorized":
|
|
return tx("settings.mcp.activatingTools", "Activating tools...");
|
|
case "connected":
|
|
return tx("settings.mcp.connected", "Connected.");
|
|
case "failed":
|
|
return tx("settings.mcp.connectionFailed", "Connection failed.");
|
|
case "cancelled":
|
|
return tx("settings.mcp.connectionCancelled", "Connection cancelled.");
|
|
}
|
|
}
|
|
|
|
function mcpPresetStatusLabel(
|
|
status: string,
|
|
tx: (key: string, fallback: string) => string,
|
|
): string {
|
|
switch (status) {
|
|
case "configured":
|
|
return tx("settings.mcp.statusConfigured", "Configured");
|
|
case "missing_credentials":
|
|
return tx("settings.mcp.statusMissingCredentials", "Needs key");
|
|
case "missing_dependency":
|
|
return tx("settings.mcp.statusMissingDependency", "Needs dependency");
|
|
case "coming_soon":
|
|
return tx("settings.mcp.statusComingSoon", "Coming soon");
|
|
default:
|
|
return tx("settings.mcp.statusNotInstalled", "Not enabled");
|
|
}
|
|
}
|
|
|
|
function McpPresetLogo({
|
|
preset,
|
|
showBrandLogos,
|
|
compact = false,
|
|
}: {
|
|
preset: McpPresetInfo;
|
|
showBrandLogos: boolean;
|
|
compact?: boolean;
|
|
}) {
|
|
const bg = preset.brand_color || "hsl(var(--muted))";
|
|
const logoUrls = useMemo(() => logoFallbackUrls(preset.logo_url), [preset.logo_url]);
|
|
const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
|
const packagedLogo = preset.logo_url?.startsWith("data:image/") === true;
|
|
const initials = preset.display_name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase())
|
|
.join("") || preset.name.slice(0, 2).toUpperCase();
|
|
|
|
if ((showBrandLogos || packagedLogo) && logoUrl) {
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"grid shrink-0 place-items-center border border-border/45 bg-background",
|
|
compact ? "h-10 w-10 rounded-[10px]" : "h-11 w-11 rounded-[8px]",
|
|
)}
|
|
>
|
|
<img
|
|
src={logoUrl}
|
|
alt=""
|
|
decoding="async"
|
|
loading="lazy"
|
|
className={cn("object-contain", compact ? "h-[22px] w-[22px]" : "h-6 w-6")}
|
|
onLoad={onLogoLoad}
|
|
onError={onLogoError}
|
|
/>
|
|
</span>
|
|
);
|
|
}
|
|
return (
|
|
<span
|
|
className={cn(
|
|
"grid shrink-0 place-items-center font-semibold text-white",
|
|
compact
|
|
? "h-10 w-10 rounded-[10px] text-[12px]"
|
|
: "h-11 w-11 rounded-[8px] text-[13px]",
|
|
)}
|
|
style={{ backgroundColor: bg }}
|
|
>
|
|
{initials}
|
|
</span>
|
|
);
|
|
}
|
|
|
|
function CliAppReadyPanel({
|
|
app,
|
|
showBrandLogos,
|
|
onBackToChat,
|
|
}: {
|
|
app: CliAppInfo;
|
|
showBrandLogos: boolean;
|
|
onBackToChat: () => void;
|
|
}) {
|
|
const { t } = useTranslation();
|
|
const [copied, setCopied] = useState(false);
|
|
const prompt = t("settings.cliApps.readyPrompt", {
|
|
name: app.name,
|
|
defaultValue: "Use @{{name}} to inspect what this CLI can do.",
|
|
});
|
|
const copyPrompt = () => {
|
|
if (!navigator.clipboard) return;
|
|
void navigator.clipboard.writeText(prompt).then(() => {
|
|
setCopied(true);
|
|
window.setTimeout(() => setCopied(false), 1400);
|
|
});
|
|
};
|
|
|
|
return (
|
|
<section className="rounded-[12px] bg-settings-surface px-4 py-3">
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center">
|
|
<CliAppLogo app={app} showBrandLogos={showBrandLogos} />
|
|
<div className="min-w-0 flex-1">
|
|
<div className="flex min-w-0 flex-wrap items-center gap-2">
|
|
<h3 className="truncate text-[14px] font-semibold leading-5 text-foreground">
|
|
{app.display_name}
|
|
</h3>
|
|
<span className="inline-flex shrink-0 items-center gap-1 rounded-full bg-muted px-2 py-0.5 text-[10.5px] font-medium text-muted-foreground">
|
|
<Check className="h-3 w-3 text-emerald-600 dark:text-emerald-300" aria-hidden />
|
|
{t("settings.cliApps.readyStatus", { defaultValue: "Ready" })}
|
|
</span>
|
|
</div>
|
|
<div className="mt-0.5 flex min-w-0 flex-wrap items-center gap-1.5 text-[12px] text-muted-foreground">
|
|
<span className="font-mono">@{app.name}</span>
|
|
<span aria-hidden>·</span>
|
|
<span className="truncate font-mono">{app.entry_point || app.name}</span>
|
|
<span aria-hidden>·</span>
|
|
<span>{app.category}</span>
|
|
</div>
|
|
</div>
|
|
<div className="flex shrink-0 flex-wrap gap-2">
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
variant="ghost"
|
|
onClick={copyPrompt}
|
|
className="h-8 rounded-full px-3 text-[12px] font-medium text-muted-foreground hover:bg-muted/65 hover:text-foreground"
|
|
>
|
|
{copied ? <Check className="mr-1.5 h-3.5 w-3.5" aria-hidden /> : null}
|
|
{copied
|
|
? t("settings.cliApps.readyCopied", { defaultValue: "Copied" })
|
|
: t("settings.cliApps.readyTry", { name: app.name, defaultValue: "Try @{{name}}" })}
|
|
</Button>
|
|
<Button
|
|
type="button"
|
|
size="sm"
|
|
onClick={onBackToChat}
|
|
className="h-8 rounded-full px-3 text-[12px] font-semibold"
|
|
>
|
|
{t("settings.cliApps.openChat", { defaultValue: "Open chat" })}
|
|
<ChevronRight className="ml-1.5 h-3.5 w-3.5" aria-hidden />
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
);
|
|
}
|
|
|
|
function CliAppLogo({ app, showBrandLogos }: { app: CliAppInfo; showBrandLogos: boolean }) {
|
|
const logoUrls = useMemo(
|
|
() => (isGenericRepositoryLogoUrl(app.logo_url) ? [] : logoFallbackUrls(app.logo_url)),
|
|
[app.logo_url],
|
|
);
|
|
const { logoUrl, logoLoaded, onLogoError, onLogoLoad } = useLogoFallback(logoUrls);
|
|
const initials = app.display_name
|
|
.split(/\s+/)
|
|
.filter(Boolean)
|
|
.slice(0, 2)
|
|
.map((part) => part[0]?.toUpperCase())
|
|
.join("") || app.name.slice(0, 2).toUpperCase();
|
|
|
|
const showRemoteLogo = showBrandLogos && Boolean(logoUrl);
|
|
|
|
return (
|
|
<span
|
|
className="relative grid h-11 w-11 shrink-0 place-items-center overflow-hidden rounded-[8px] border border-border/45 bg-muted text-[13px] font-semibold"
|
|
style={{ color: app.brand_color || "hsl(var(--muted-foreground))" }}
|
|
>
|
|
<span
|
|
aria-hidden
|
|
className={cn(
|
|
"transition-opacity duration-150 motion-reduce:transition-none",
|
|
showRemoteLogo && logoLoaded ? "opacity-0" : "opacity-100",
|
|
)}
|
|
>
|
|
{initials}
|
|
</span>
|
|
{showRemoteLogo ? (
|
|
<img
|
|
src={logoUrl}
|
|
alt=""
|
|
decoding="async"
|
|
loading="lazy"
|
|
referrerPolicy="no-referrer"
|
|
draggable={false}
|
|
className={cn(
|
|
"absolute h-6 w-6 object-contain transition-opacity duration-150 motion-reduce:transition-none",
|
|
logoLoaded ? "opacity-100" : "opacity-0",
|
|
)}
|
|
onLoad={onLogoLoad}
|
|
onError={onLogoError}
|
|
/>
|
|
) : null}
|
|
</span>
|
|
);
|
|
}
|