"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 ? (

setImgError(true)} />
) : (
{textIcon}
)}
{/* Provider name */}
{label}
{/* Active indicator */}
{active && (
)}
);
}
ProviderNode.propTypes = {
data: PropTypes.object.isRequired,
};
// Center 9Router node
function RouterNode({ data }) {
return (
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,
};