import { useMemo, type ReactNode } from "react"; import type { useTranslation } from "react-i18next"; import { channelFieldMessageKey, channelTranslator, } from "@/channel-plugins/i18n"; import { channelLocaleMessages } from "@/channel-plugins/locale-registry"; import { channelUiOwner, channelUiPresentation, } from "@/channel-plugins/registry"; import type { ChannelConfigField, ChannelSetupPresentation, } from "@/components/settings/channels/catalog"; import { useLogoFallback } from "@/hooks/useLogoFallback"; import { normalizeLocale } from "@/i18n/config"; import { logoFallbackUrls } from "@/lib/provider-brand"; import type { ChannelRuntimeStatus, NanobotFeatureInfo } from "@/lib/types"; export type ChannelFilter = "all" | "on" | "off"; export function channelSetup( feature: NanobotFeatureInfo, locale = "en", ): ChannelSetupPresentation { const definition = channelUiPresentation(feature.name, feature.webui)?.setup; const owner = channelUiOwner(feature.name); const messages = channelLocaleMessages(owner, normalizeLocale(locale)); const setupMessages = messages?.setup; const localizeField = (key: string): ChannelConfigField => { const copy = setupMessages?.fields?.[channelFieldMessageKey(feature.name, key)]; return { key, label: copy?.label ?? fieldLabel(key.split(".").at(-1) ?? key), placeholder: copy?.placeholder, help: copy?.help, }; }; const presentation: ChannelSetupPresentation = { ...definition, primaryActionLabel: setupMessages?.primaryAction, docsLabel: setupMessages?.docsLabel, officialLabel: setupMessages?.officialLabel, summary: setupMessages?.summary ?? "Enable turns on this channel in nanobot, but this integration still needs platform-specific setup before it can receive messages.", tryIt: setupMessages?.tryIt, steps: setupMessages?.steps ?? [ `Open ~/.nanobot/config.json and find channels.${feature.name}.`, "Add the credentials required by that platform, using the channel documentation as the source of truth.", "Restart nanobot, then send a small test message from that platform.", ], fields: definition?.fields?.map((field) => localizeField(field.key)), manualFields: definition?.manualFields?.map((field) => localizeField(field.key)), actions: definition?.actions?.map((action) => ({ ...action, label: setupMessages?.actions?.[action.id] ?? fieldLabel(action.id), })), presets: definition?.presets?.map((preset) => ({ ...preset, label: setupMessages?.presets?.[preset.id] ?? fieldLabel(preset.id), })), }; const contract = feature.setup; if (!contract) return presentation; const primaryFields = new Map( (presentation.fields ?? []).map((field) => [field.key, field]), ); const manualFields = new Map( (presentation.manualFields ?? []).map((field) => [field.key, field]), ); const authoritativeFields = contract.fields.map((field): ChannelConfigField => { const local = primaryFields.get(field.key) ?? manualFields.get(field.key); const copy = local ?? localizeField(field.key); const choiceLabels = setupMessages?.fields?.[ channelFieldMessageKey(feature.name, field.key) ]?.choices ?? {}; const choices = field.kind === "bool" ? ["true", "false"] : field.choices; return { ...copy, key: field.key, label: copy.label, secret: field.kind === "secret", optional: !field.required, inputType: field.kind === "int" ? "number" : undefined, defaultValue: field.default_value, options: field.kind === "enum" || field.kind === "bool" ? choices.map((choice) => ({ value: choice, label: choiceLabels[choice] ?? fieldLabel(choice), })) : undefined, }; }); const manualKeys = new Set(manualFields.keys()); const fields = authoritativeFields.filter((field) => !manualKeys.has(field.key)); const manual = authoritativeFields.filter((field) => manualKeys.has(field.key)); return { ...presentation, officialUrl: contract.official_url, officialLabel: presentation.officialLabel ?? (contract.official_url ? "Open official setup" : undefined), fields: fields.length ? fields : undefined, manualFields: manual.length ? manual : undefined, }; } function fieldLabel(value: string): string { const spaced = value .replace(/([a-z0-9])([A-Z])/g, "$1 $2") .replace(/[_-]+/g, " ") .trim(); return spaced ? spaced[0].toUpperCase() + spaced.slice(1) : value; } export function ChannelLogo({ feature, showBrandLogos, }: { feature: NanobotFeatureInfo; showBrandLogos: boolean; }) { const presentation = channelUiPresentation(feature.name, feature.webui); const initials = presentation?.initials ?? feature.display_name.slice(0, 2).toUpperCase(); const color = presentation?.color ?? "#6B7280"; const Icon = presentation?.icon; const logoUrls = useMemo(() => logoFallbackUrls(presentation?.logoUrl), [presentation?.logoUrl]); const { logoUrl, onLogoError, onLogoLoad } = useLogoFallback(logoUrls); if (showBrandLogos && logoUrl) { return ( ); } if (Icon) { return ( ); } return ( {initials} ); } export function channelDisplayName(feature: NanobotFeatureInfo): string { return channelUiPresentation(feature.name, feature.webui)?.displayName ?? feature.display_name; } export function localizedChannelDisplayName( feature: NanobotFeatureInfo, t: ReturnType["t"], ): string { const fallback = channelDisplayName(feature); return channelTranslator(t, channelUiOwner(feature.name))("displayName", fallback); } export function channelDescription(feature: NanobotFeatureInfo, t: ReturnType["t"]): string { const fallback = `Use nanobot from ${channelDisplayName(feature)}.`; return channelTranslator(t, channelUiOwner(feature.name))("description", fallback); } export function channelRequirements(feature: NanobotFeatureInfo, t: ReturnType["t"]): string { const fallback = "Channel credentials and gateway settings"; return channelTranslator(t, channelUiOwner(feature.name))("requirements", fallback); } export function channelMatchesFilter(feature: NanobotFeatureInfo, filter: ChannelFilter): boolean { if (filter === "on") return channelIsRunning(feature); if (filter === "off") return !channelIsRunning(feature); return true; } export function channelIsRunning(feature: NanobotFeatureInfo): boolean { return feature.runtime_status === "running"; } export function channelToggleChecked(feature: NanobotFeatureInfo): boolean { return feature.runtime_status === "running" || feature.runtime_status === "starting"; } export function channelStatusLabel( feature: NanobotFeatureInfo, tx: (key: string, fallback: string) => string, ): string { if (feature.runtime_status === "failed") { return tx("settings.channels.runtimeFailed", "Failed"); } if (feature.runtime_status === "starting") { return tx("settings.channels.runtimeStarting", "Starting"); } if (channelIsRunning(feature)) return tx("settings.values.on", "On"); if (feature.enabled) return tx("settings.channels.runtimeStopped", "Not running"); return tx("settings.values.off", "Off"); } export function channelSearchText( feature: NanobotFeatureInfo, t?: ReturnType["t"], ): string { return [ t ? localizedChannelDisplayName(feature, t) : undefined, channelDisplayName(feature), feature.display_name, feature.name, feature.status, t ? channelDescription(feature, t) : undefined, t ? channelRequirements(feature, t) : undefined, ] .join(" ") .toLowerCase(); } export function ChannelStatusBadge({ children, status, }: { children: ReactNode; status?: ChannelRuntimeStatus; }) { return ( {children} ); } export function ChannelRuntimeError({ message, className = "mt-3", }: { message?: string; className?: string; }) { if (!message) return null; return (
{message}
); }