import React, { useCallback, useEffect, useMemo, useState } from 'react'; import { Cpu, Mic, MessageSquare, Activity, AlertTriangle, CheckCircle2, RefreshCw, Layers } from 'lucide-react'; import { toast } from 'react-hot-toast'; import { listEngines, getEngineHealth } from '../api/engines'; import { Badge, Button, Segmented, Table } from '../ui'; import SupertonicLicenseDialog from './SupertonicLicenseDialog'; import './EngineCompatibilityMatrix.css'; /** Engines that gate first use behind an in-app license acceptance dialog. * Phase 3 Plan 03-01 ‑‑ Supertonic-3 today; future OpenRAIL-M engines * add themselves here alongside an in-tree dialog component. */ const LICENSE_DIALOGS = { supertonic3: SupertonicLicenseDialog, }; /** Heuristic detector for the "license not accepted" backend reason * message produced by Supertonic3Backend.is_available(). The backend * message reads "Supertonic-3 license not accepted ..." so this prefix * match is robust to wording tweaks. */ function reasonMentionsLicense(reason) { if (!reason || typeof reason !== 'string') return false; return /license not accepted/i.test(reason); } /** * Engine Compatibility Matrix (Plan 02-04 / ENGINE-06). * * Renders a single source-of-truth table of every registered backend in * a family (tts / asr / llm). Each row shows: * * Engine display name * * Install state (available / unavailable, with the failure reason * inline when the row is unavailable) * * GPU compat chips (cuda / mps / rocm / cpu) * * Isolation mode (in-process or subprocess) — the visible payoff * of the Plan 02-01 SubprocessBackend + Plan 02-03 IndexTTS migration * * Last error (cached most-recent failure — distinguishes "currently * failing" from "failed before, now working") * * Test engine button — fires a `/engines/{id}/health` round-trip on * demand; SubprocessBackend rows spawn-and-ping their sidecar, in- * process rows fall back to `is_available()`. Latency is rendered * inline next to the button. * * Cross-platform contract: this component does NOT auto-spawn any * sidecar on mount; the user must click Test engine. That keeps macOS / * Windows / Linux behaviour identical and prevents the matrix from * locking up a cold IndexTTS install for 30 s every time Settings * loads. A short 5 s cooldown on the Test button prevents click-storms. * * Props: * - family: 'tts' | 'asr' | 'llm' default 'tts' * - onSelect?: (family, backendId) => Promise optional — when * provided, a "Use" button appears next to "Test engine" for * available, non-active rows. Lets the matrix double as an engine * picker so Settings doesn't need a parallel table. * - activeId?: string the currently-active backend id for this * family. Used to render the "active" badge. */ const FAMILY_META = { tts: { label: 'TTS', icon: Cpu }, asr: { label: 'ASR', icon: Mic }, llm: { label: 'LLM', icon: MessageSquare }, }; const ISOLATION_TONE = { subprocess: 'info', 'in-process': 'neutral', }; const GPU_LABEL = { cuda: 'CUDA', mps: 'MPS', rocm: 'ROCm', cpu: 'CPU', }; const TEST_COOLDOWN_MS = 5000; const COLUMNS = [ { key: 'name', label: 'Engine', flex: 3 }, { key: 'status', label: 'Install state', width: 130, align: 'center' }, { key: 'gpu', label: 'GPU compat', width: 170, align: 'left' }, { key: 'isolation', label: 'Isolation', width: 110, align: 'center' }, { key: 'action', label: 'Actions', width: 220, align: 'right' }, ]; /** Subset of the unified engine entry the matrix actually reads. */ function normalizeEntry(entry) { return { id: entry.id, display_name: entry.display_name, available: !!entry.available, reason: entry.reason || null, install_hint: entry.install_hint || null, last_error: entry.last_error || null, isolation_mode: entry.isolation_mode || 'in-process', gpu_compat: Array.isArray(entry.gpu_compat) && entry.gpu_compat.length > 0 ? entry.gpu_compat : ['cpu'], }; } export default function EngineCompatibilityMatrix({ family = 'tts', onSelect = null, activeId = null, // Test-friendly overrides — let the RTL suite mock the API layer // without resorting to module-level vi.mock incantations. apiListEngines = listEngines, apiGetEngineHealth = getEngineHealth, }) { const [data, setData] = useState(null); const [loading, setLoading] = useState(true); const [error, setError] = useState(null); const [activeFamily, setActiveFamily] = useState(family); // Phase 3 Plan 03-01 / TTS-05: which engine has its license dialog // currently open, or null. Only one dialog is ever open at a time. const [licenseDialogFor, setLicenseDialogFor] = useState(null); // health state keyed by engine id: // { [id]: { inflight: boolean, ok?: boolean, message?: string, // latency_ms?: number, lastClickAt?: number } } const [healthByEngine, setHealthByEngine] = useState({}); useEffect(() => { setActiveFamily(family); }, [family]); const reload = useCallback(async () => { setLoading(true); setError(null); try { const fresh = await apiListEngines(); setData(fresh); } catch (e) { const msg = e?.message || String(e); setError(msg); toast.error(`Failed to load engines: ${msg}`); } finally { setLoading(false); } }, [apiListEngines]); useEffect(() => { reload(); }, [reload]); const familyData = data?.[activeFamily]; const backends = useMemo( () => (familyData?.backends || []).map(normalizeEntry), [familyData], ); const families = useMemo( () => Object.keys(FAMILY_META).filter((f) => data?.[f]?.backends), [data], ); const testHealth = useCallback(async (id) => { const now = Date.now(); const cur = healthByEngine[id]; if (cur?.inflight) return; if (cur?.lastClickAt && now - cur.lastClickAt < TEST_COOLDOWN_MS) { // Click-storm cooldown — silently ignore. return; } setHealthByEngine((prev) => ({ ...prev, [id]: { inflight: true, lastClickAt: now }, })); try { const result = await apiGetEngineHealth(id); setHealthByEngine((prev) => ({ ...prev, [id]: { inflight: false, ok: !!result.ok, message: result.message || '', latency_ms: Math.round(result.latency_ms || 0), lastClickAt: now, }, })); } catch (e) { setHealthByEngine((prev) => ({ ...prev, [id]: { inflight: false, ok: false, message: e?.message || String(e), latency_ms: 0, lastClickAt: now, }, })); } }, [apiGetEngineHealth, healthByEngine]); if (loading && !data) { return (
Loading engines…
); } if (error && !data) { return (
Could not load engines: {error}
); } if (!familyData) return null; const activeBackendId = activeId ?? familyData.active; return (

Engine Compatibility Matrix

{families.length > 1 && ( ({ value: f, label: `${FAMILY_META[f].label} · ${data[f].active}`, }))} /> )}
{backends.map((b) => { const isActive = b.id === activeBackendId; const health = healthByEngine[b.id]; return (
{/* Engine name + reason / install_hint */}
{b.display_name} {isActive && active} {b.id} {!b.available && b.reason && ( {b.reason} )} {b.install_hint && ( {b.install_hint} )} {b.last_error && ( Last error: {b.last_error} )}
{/* Install state */}
{b.available ? Available : Unavailable}
{/* GPU compat chips */}
{b.gpu_compat.map((g) => ( {GPU_LABEL[g] || g.toUpperCase()} ))}
{/* Isolation mode */}
{b.isolation_mode}
{/* Actions: Test engine + optional Use */}
{health && !health.inflight && ( {health.ok ? `${health.latency_ms} ms` : `failed`} )} {onSelect && b.available && !isActive && ( )} {/* TTS-05: license-acceptance entry point. Surfaced when the backend says the user hasn't accepted the engine's license yet AND we have a dialog registered for that engine id. */} {!b.available && reasonMentionsLicense(b.reason) && LICENSE_DIALOGS[b.id] && ( )}
); })} {backends.length === 0 && (
No backends registered.
)}
); }