"use client"; /** * Channel configuration panel — Partners channel-config logic preserved: * schema-driven form for any channel (built-in or plugin), secret masking * with explicit reveal, delivery flags, and live listener reload on save. */ import { useCallback, useEffect, useState } from "react"; import { Loader2, Save } from "lucide-react"; import { useTranslation } from "react-i18next"; import { apiFetch, apiUrl } from "@/lib/api"; import { getChannelSchemas, type ChannelsSchemaResponse, } from "@/lib/partners-api"; import { SchemaField, defaultFor, type JsonSchema, } from "@/components/partners/schema-form"; import ChannelIcon from "@/components/partners/ChannelIcon"; const LEGACY_GLOBAL_DELIVERY_KEYS = new Set([ "send_progress", "send_tool_hints", "sendProgress", "sendToolHints", ]); function stripLegacyGlobalDelivery(channels: Record) { return Object.fromEntries( Object.entries(channels).filter( ([key]) => !LEGACY_GLOBAL_DELIVERY_KEYS.has(key), ), ); } export default function PartnerChannels({ partnerId, onToast, }: { partnerId: string; onToast: (msg: string) => void; }) { const { t } = useTranslation(); const [schemaCatalog, setSchemaCatalog] = useState(null); const [channels, setChannels] = useState>({}); const [activeChannel, setActiveChannel] = useState(null); const [reloadError, setReloadError] = useState(null); const [loadingDetail, setLoadingDetail] = useState(true); const [saving, setSaving] = useState(false); /** dot-paths of secrets the user has explicitly toggled to plaintext. */ const [revealed, setRevealed] = useState>(new Set()); useEffect(() => { void (async () => { try { setSchemaCatalog(await getChannelSchemas()); } catch { /* leave catalog null → renders fallback message */ } })(); }, []); const loadDetail = useCallback(async () => { setLoadingDetail(true); try { // Edit form needs raw secrets to populate fields. Default GET masks them. const res = await apiFetch( apiUrl(`/api/v1/partners/${partnerId}?include_secrets=true`), ); if (!res.ok) return; const data = await res.json(); const raw = (data.channels ?? {}) as Record; setChannels(stripLegacyGlobalDelivery(raw)); setReloadError( typeof data.last_reload_error === "string" ? data.last_reload_error : null, ); } finally { setLoadingDetail(false); } }, [partnerId]); useEffect(() => { setRevealed(new Set()); void loadDetail(); }, [loadDetail]); // Default active channel: prefer one already enabled. useEffect(() => { if (activeChannel || !schemaCatalog) return; const names = Object.keys(schemaCatalog.channels); const enabled = names.find((n) => { const cfg = channels[n]; return ( cfg && typeof cfg === "object" && (cfg as Record).enabled === true ); }); setActiveChannel(enabled ?? names[0] ?? null); }, [schemaCatalog, channels, activeChannel]); const toggleSecret = useCallback((path: string) => { setRevealed((prev) => { const next = new Set(prev); if (next.has(path)) next.delete(path); else next.add(path); return next; }); }, []); const setActiveChannelConfig = (next: unknown) => { if (!activeChannel) return; setChannels((prev) => ({ ...prev, [activeChannel]: next })); }; const save = async () => { setSaving(true); try { const res = await apiFetch(apiUrl(`/api/v1/partners/${partnerId}`), { method: "PATCH", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ channels: stripLegacyGlobalDelivery(channels) }), }); if (res.ok) { onToast(t("Channels saved")); await loadDetail(); } else if (res.status === 422) { const err = (await res.json().catch(() => ({}))) as { detail?: { message?: string } | string; }; const detail = err.detail; onToast( typeof detail === "string" ? detail : (detail?.message ?? t("Invalid channel configuration")), ); } else { const err = (await res.json().catch(() => ({}))) as { detail?: string }; onToast(err.detail ?? t("Save failed")); } } catch (error) { onToast(error instanceof Error ? error.message : t("Save failed")); } finally { setSaving(false); } }; if (loadingDetail || !schemaCatalog) { return (
); } const channelEntries = Object.entries(schemaCatalog.channels).sort( ([, a], [, b]) => a.display_name.localeCompare(b.display_name), ); const activeEntry = activeChannel ? schemaCatalog.channels[activeChannel] : undefined; const activeValue = activeChannel && channels[activeChannel] && typeof channels[activeChannel] === "object" ? (channels[activeChannel] as Record) : (activeEntry?.default_config ?? {}); const activeSecretSet = new Set(activeEntry?.secret_fields ?? []); return (
{reloadError && (
{t("Channel listeners failed to restart:")} {" "} {reloadError}{" "} {t("Config is saved on disk; stop and start the partner to apply.")}
)}
{/* Channel master-detail */}
{!activeEntry ? (

{t("Select a channel.")}

) : ( <>

{activeEntry.display_name}

{activeEntry.name}
{activeEntry.available === false || !activeEntry.json_schema ? (
{t("This channel is not available on the server.")} {" "} {activeEntry.unavailable_reason && ( {activeEntry.unavailable_reason} )}
) : ( <> {(activeEntry.json_schema as JsonSchema).description && (

{(activeEntry.json_schema as JsonSchema).description}

)} {Object.entries( (activeEntry.json_schema as JsonSchema).properties ?? {}, ).map(([k, child]) => ( setActiveChannelConfig({ ...activeValue, [k]: next }) } secretFields={activeSecretSet} path={k} showSecretFor={revealed} toggleSecret={toggleSecret} /> ))} )} )}
); }