"use client"; import { useMemo, useState, useEffect, useCallback, useRef } from "react"; import PropTypes from "prop-types"; import { ReactFlow, Handle, Position, Controls, } from "@xyflow/react"; import "@xyflow/react/dist/style.css"; import { AI_PROVIDERS } from "@/shared/constants/providers"; // Force-stop FE animation if a provider stays active longer than this const FE_ACTIVE_TIMEOUT_MS = 60000; const FE_ACTIVE_TICK_MS = 1000; function getProviderConfig(providerId) { return AI_PROVIDERS[providerId] || { color: "#6b7280", name: providerId }; } // Use local provider images from /public/providers/ function getProviderImageUrl(providerId) { return `/providers/${providerId}.png`; } // Custom provider node - rectangle with image + name function ProviderNode({ data }) { const { label, color, imageUrl, textIcon, active } = data; const [imgError, setImgError] = useState(false); return (
{/* Provider icon */}
{!imgError ? ( {label} setImgError(true)} /> ) : ( {textIcon} )}
{/* Provider name */} {label} {/* Active indicator */} {active && ( )}
); } ProviderNode.propTypes = { data: PropTypes.object.isRequired, }; // Center 9Router node function RouterNode({ data }) { return (
9Router 9Router {data.activeCount > 0 && ( {data.activeCount} )}
); } RouterNode.propTypes = { data: PropTypes.object.isRequired, }; const nodeTypes = { provider: ProviderNode, router: RouterNode }; // Place N nodes evenly along an ellipse around the router center. function buildLayout(providers, activeSet, lastSet, errorSet) { const nodeW = 180; const nodeH = 30; const routerW = 120; const routerH = 44; const nodeGap = 24; const count = providers.length; // Compute rx so arc spacing between nodes >= nodeW + nodeGap const minRx = ((nodeW + nodeGap) * count) / (2 * Math.PI); const rx = Math.max(320, minRx); const ry = Math.max(200, rx * 0.55); // ellipse ratio ~0.55 if (count === 0) { return { nodes: [{ id: "router", type: "router", position: { x: 0, y: 0 }, data: { activeCount: 0 }, draggable: false }], edges: [], }; } const nodes = []; const edges = []; nodes.push({ id: "router", type: "router", position: { x: -routerW / 2, y: -routerH / 2 }, data: { activeCount: activeSet.size }, draggable: false, }); const edgeStyle = (active, last, error, color) => { if (error) return { stroke: "#ef4444", strokeWidth: 2.5, opacity: 0.9 }; if (active) return { stroke: "#22c55e", strokeWidth: 2.5, opacity: 0.9 }; if (last) return { stroke: "#f59e0b", strokeWidth: 2, opacity: 0.7 }; return { stroke: "var(--color-border)", strokeWidth: 1, opacity: 0.3 }; }; providers.forEach((p, i) => { const config = getProviderConfig(p.provider); const active = activeSet.has(p.provider?.toLowerCase()); const last = !active && lastSet.has(p.provider?.toLowerCase()); const error = !active && errorSet.has(p.provider?.toLowerCase()); const nodeId = `provider-${p.provider}`; const data = { label: (config.name !== p.provider ? config.name : null) || p.nodeName || p.name || p.provider, color: config.color || "#6b7280", imageUrl: getProviderImageUrl(p.provider), textIcon: config.textIcon || (p.provider || "?").slice(0, 2).toUpperCase(), active, }; // Distribute evenly starting from top (−π/2), clockwise const angle = -Math.PI / 2 + (2 * Math.PI * i) / count; const cx = rx * Math.cos(angle); const cy = ry * Math.sin(angle); // Pick router handle closest to the node direction let sourceHandle, targetHandle; if (Math.abs(angle + Math.PI / 2) < Math.PI / 4 || Math.abs(angle - 3 * Math.PI / 2) < Math.PI / 4) { sourceHandle = "top"; targetHandle = "bottom"; } else if (Math.abs(angle - Math.PI / 2) < Math.PI / 4) { sourceHandle = "bottom"; targetHandle = "top"; } else if (cx > 0) { sourceHandle = "right"; targetHandle = "left"; } else { sourceHandle = "left"; targetHandle = "right"; } nodes.push({ id: nodeId, type: "provider", position: { x: cx - nodeW / 2, y: cy - nodeH / 2 }, data, draggable: false, }); edges.push({ id: `e-${nodeId}`, source: "router", sourceHandle, target: nodeId, targetHandle, animated: active, style: edgeStyle(active, last, error, config.color), }); }); return { nodes, edges }; } export default function ProviderTopology({ providers = [], activeRequests = [], lastProvider = "", errorProvider = "" }) { // Serialize to stable string keys so useMemo only re-runs when values actually change const activeKey = useMemo( () => activeRequests.map((r) => r.provider?.toLowerCase()).filter(Boolean).sort().join(","), [activeRequests] ); const lastKey = lastProvider?.toLowerCase() || ""; const errorKey = errorProvider?.toLowerCase() || ""; const rawActiveSet = useMemo(() => new Set(activeKey ? activeKey.split(",") : []), [activeKey]); const lastSet = useMemo(() => new Set(lastKey ? [lastKey] : []), [lastKey]); const errorSet = useMemo(() => new Set(errorKey ? [errorKey] : []), [errorKey]); // Track firstSeen per active provider; drop provider if running too long (BE stuck) const firstSeenRef = useRef({}); const [tick, setTick] = useState(0); useEffect(() => { const seen = firstSeenRef.current; const now = Date.now(); for (const p of rawActiveSet) { if (!seen[p]) seen[p] = now; } for (const p of Object.keys(seen)) { if (!rawActiveSet.has(p)) delete seen[p]; } }, [rawActiveSet]); useEffect(() => { if (rawActiveSet.size === 0) return; const id = setInterval(() => setTick((t) => t + 1), FE_ACTIVE_TICK_MS); return () => clearInterval(id); }, [rawActiveSet]); const activeSet = useMemo(() => { const now = Date.now(); const filtered = new Set(); for (const p of rawActiveSet) { const ts = firstSeenRef.current[p]; if (!ts || now - ts < FE_ACTIVE_TIMEOUT_MS) filtered.add(p); } return filtered; }, [rawActiveSet, tick]); const { nodes, edges } = useMemo( () => buildLayout(providers, activeSet, lastSet, errorSet), [providers, activeSet, lastKey, errorKey] ); // Stable key — only remount when provider list changes const providersKey = useMemo( () => providers.map((p) => p.provider).sort().join(","), [providers] ); const rfInstance = useRef(null); const containerRef = useRef(null); const fitOpts = { padding: 0.2, duration: 200 }; const onInit = useCallback((instance) => { rfInstance.current = instance; setTimeout(() => instance.fitView(fitOpts), 50); }, []); // Re-fit on container resize useEffect(() => { const el = containerRef.current; if (!el) return; const ro = new ResizeObserver(() => { if (rfInstance.current) rfInstance.current.fitView(fitOpts); }); ro.observe(el); return () => ro.disconnect(); }, []); // Re-fit when node count/layout changes useEffect(() => { if (rfInstance.current) { const id = setTimeout(() => rfInstance.current.fitView(fitOpts), 50); return () => clearTimeout(id); } }, [nodes.length]); return (
{providers.length === 0 ? (
No providers connected
) : ( )}
); } ProviderTopology.propTypes = { providers: PropTypes.arrayOf(PropTypes.shape({ id: PropTypes.string, provider: PropTypes.string, name: PropTypes.string, })), activeRequests: PropTypes.arrayOf(PropTypes.shape({ provider: PropTypes.string, model: PropTypes.string, account: PropTypes.string, })), lastProvider: PropTypes.string, errorProvider: PropTypes.string, };