import { useEffect, useMemo, useState, type ReactNode } from "react"; import { ChevronDown, Loader2 } from "lucide-react"; import { useTranslation } from "react-i18next"; import { channelUiPresentation } from "@/channel-plugins/registry"; import { ToggleButton } from "@/components/settings/ToggleButton"; import type { ChannelConfigField } from "@/components/settings/channels/catalog"; import { CredentialForm, channelValidationStatusClass, channelValidationStatusIcon, channelValuesForSave, defaultChannelFieldValues, } from "@/components/settings/channels/CredentialForm"; import { ChannelLogo, ChannelRuntimeError, ChannelStatusBadge, channelSetup, channelStatusLabel, localizedChannelDisplayName, } from "@/components/settings/channels/ChannelIdentity"; import { ChannelGuideLink, ChannelSetupSteps, } from "@/components/settings/channels/ChannelSetupParts"; import { Button } from "@/components/ui/button"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { configureChannel, disableNanobotFeature, enableNanobotFeature, } from "@/lib/api"; import { logoFallbackUrls } from "@/lib/provider-brand"; import type { NanobotChannelInstanceInfo, NanobotFeatureInfo, NanobotFeaturesPayload, } from "@/lib/types"; import { cn } from "@/lib/utils"; export type ChannelInstancesPanelCustomization = { countLabel?: (runningCount: number) => string; toggleAriaLabel?: (instance: NanobotChannelInstanceInfo) => string; configuredLabel?: string; needsSetupLabel?: string; renderInstanceSummary?: (instance: NanobotChannelInstanceInfo) => ReactNode; renderInstanceAction?: (instance: NanobotChannelInstanceInfo) => ReactNode; footer?: ReactNode; }; export function ChannelInstancesPanel({ token, feature, showBrandLogos, chatAppsDocsUrl, instances: providedInstances, onFeaturesUpdate, customization = {}, }: { token: string; feature: NanobotFeatureInfo; showBrandLogos: boolean; chatAppsDocsUrl?: string; instances?: NanobotChannelInstanceInfo[]; onFeaturesUpdate: (payload: NanobotFeaturesPayload) => void; customization?: ChannelInstancesPanelCustomization; }) { const { t, i18n } = useTranslation(); const tx = (key: string, fallback: string) => t(key, { defaultValue: fallback }); const displayName = localizedChannelDisplayName(feature, t); const instances = providedInstances ?? feature.instances ?? []; const [selectedId, setSelectedId] = useState(null); const [busyInstanceId, setBusyInstanceId] = useState(null); const [notice, setNotice] = useState(null); const selected = selectedId ? instances.find((instance) => instance.id === selectedId) : undefined; const setup = useMemo( () => channelSetup(feature, i18n.resolvedLanguage ?? i18n.language), [feature.name, feature.setup, i18n.language, i18n.resolvedLanguage], ); const instanceFields = useMemo( () => channelInstanceFields(feature, setup.fields, setup.manualFields), [feature, setup.fields, setup.manualFields], ); const [fieldValues, setFieldValues] = useState>(() => defaultChannelFieldValues(instanceFields, selected?.config_values), ); const [visibleSecrets, setVisibleSecrets] = useState>({}); const [savingFields, setSavingFields] = useState(false); const configuredCount = instances.filter((instance) => instance.configured).length; const runningCount = instances.filter((instance) => instance.runtime_status === "running").length; const selectedValuesKey = JSON.stringify(selected?.config_values ?? {}); const selectedConfiguredFields = useMemo( () => new Set(selected?.configured_fields ?? []), [selected?.configured_fields], ); useEffect(() => { if (selectedId && !instances.some((instance) => instance.id === selectedId)) { setSelectedId(null); } }, [instances, selectedId]); useEffect(() => { setFieldValues(defaultChannelFieldValues(instanceFields, selected?.config_values)); setVisibleSecrets({}); }, [instanceFields, selected?.id, selectedValuesKey]); const toggleInstance = async (instance: NanobotChannelInstanceInfo, checked: boolean) => { setBusyInstanceId(instance.id); setNotice(null); try { const payload = checked ? await enableNanobotFeature(token, feature.name, { instanceId: instance.id }) : await disableNanobotFeature(token, feature.name, { instanceId: instance.id }); onFeaturesUpdate(payload); } catch (err) { setNotice((err as Error).message); } finally { setBusyInstanceId(null); } }; const saveSelectedInstanceSettings = async () => { if (!selected) return; setSavingFields(true); setNotice(null); try { const payload = await configureChannel( token, feature.name, channelValuesForSave(instanceFields, fieldValues), { enable: selected.enabled, instanceId: selected.id }, ); if (payload.nanobot_features) { onFeaturesUpdate(payload.nanobot_features); } setNotice(tx("settings.channels.savedSettings", "Saved settings.")); } catch (err) { setNotice((err as Error).message); } finally { setSavingFields(false); } }; return ( ); } function channelInstanceDisplayName(instance: NanobotChannelInstanceInfo): string { const displayName = instance.display_name?.trim(); if (displayName) return displayName; const localName = instance.name?.trim(); if (localName) return localName; return instance.id; } function instanceToggleChecked(instance: NanobotChannelInstanceInfo): boolean { return instance.runtime_status === "running" || instance.runtime_status === "starting"; } function ChannelInstanceStatusBadge({ instance, configuredLabel, needsSetupLabel, }: { instance: NanobotChannelInstanceInfo; configuredLabel?: string; needsSetupLabel?: string; }) { const { t } = useTranslation(); let status = instance.configured ? "configured" : "needs_setup"; let label = instance.configured ? t("settings.channels.instanceConfigured", { defaultValue: "Configured" }) : needsSetupLabel ?? t("settings.channels.instanceNeedsSetup", { defaultValue: "Needs setup" }); if (instance.runtime_status === "failed") { status = "invalid"; label = t("settings.channels.runtimeFailed", { defaultValue: "Failed" }); } else if (instance.runtime_status === "starting") { label = t("settings.channels.runtimeStarting", { defaultValue: "Starting" }); } else if (instance.enabled && instance.runtime_status !== "running") { label = t("settings.channels.runtimeStopped", { defaultValue: "Not running" }); } else if (instance.runtime_status === "running") { status = "connected"; label = configuredLabel ?? t("settings.channels.validation.connected", { defaultValue: "Connected" }); } return ( {channelValidationStatusIcon(status)} {label} ); } function ChannelInstanceAvatar({ feature, instance, showBrandLogos, }: { feature: NanobotFeatureInfo; instance: NanobotChannelInstanceInfo; showBrandLogos: boolean; }) { const presentation = channelUiPresentation(feature.name, feature.webui); const [avatarFailed, setAvatarFailed] = useState(false); const fallbackLogoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(fallbackLogoUrls); const remoteAvatarUrl = !avatarFailed ? instance.avatar_url?.trim() : ""; const imageUrl = remoteAvatarUrl || (showBrandLogos ? logoUrl : ""); const Icon = presentation?.icon; const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase(); const color = presentation?.color ?? "#3370FF"; useEffect(() => { setAvatarFailed(false); }, [instance.avatar_url]); return ( {remoteAvatarUrl ? ( setAvatarFailed(true)} /> ) : imageUrl ? ( ) : Icon ? ( ) : ( initials )} ); } function channelInstanceFields( feature: NanobotFeatureInfo, fields: ChannelConfigField[] | undefined, manualFields: ChannelConfigField[] | undefined, ): ChannelConfigField[] { const available = new Map( [...(fields ?? []), ...(manualFields ?? [])].map((field) => [field.key, field]), ); if (!feature.setup) return [...available.values()]; return feature.setup.fields.flatMap((field) => { const resolved = available.get(field.key); return resolved ? [resolved] : []; }); }