OmniVoice-Studio / frontend /src /components /EngineCompatibilityMatrix.jsx
Lê Phi Nam
Deploy to HF Space
94004a2
Raw
History Blame Contribute Delete
14.3 kB
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<void> 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 (
<section className="engine-matrix engine-matrix--loading" aria-busy="true">
<span className="engine-matrix__muted">Loading engines…</span>
</section>
);
}
if (error && !data) {
return (
<section className="engine-matrix engine-matrix--error" role="alert">
<AlertTriangle size={14} /> Could not load engines: {error}
<Button size="sm" variant="subtle" onClick={reload} leading={<RefreshCw size={11} />}>
Retry
</Button>
</section>
);
}
if (!familyData) return null;
const activeBackendId = activeId ?? familyData.active;
return (
<section className="engine-matrix">
<header className="engine-matrix__head">
<h3 className="engine-matrix__title">
<Layers size={14} /> Engine Compatibility Matrix
</h3>
<Button
size="sm"
variant="subtle"
onClick={reload}
loading={loading}
leading={<RefreshCw size={11} />}
>
Refresh
</Button>
</header>
{families.length > 1 && (
<Segmented
size="sm"
value={activeFamily}
onChange={setActiveFamily}
items={families.map((f) => ({
value: f,
label: `${FAMILY_META[f].label} · ${data[f].active}`,
}))}
/>
)}
<Table className="engine-matrix__table" role="table" aria-label={`${activeFamily} engine compatibility`}>
<Table.Header columns={COLUMNS} />
<div className="engine-matrix__body" role="rowgroup">
{backends.map((b) => {
const isActive = b.id === activeBackendId;
const health = healthByEngine[b.id];
return (
<div
key={b.id}
role="row"
data-engine-id={b.id}
className={`engine-matrix__row ${b.available ? 'is-ok' : 'is-off'}`}
>
{/* Engine name + reason / install_hint */}
<div role="cell" className="engine-matrix__cell engine-matrix__cell--name" style={{ flex: 3 }}>
<span className="engine-matrix__name">
{b.display_name}
{isActive && <Badge tone="brand" size="xs">active</Badge>}
</span>
<code className="engine-matrix__id">{b.id}</code>
{!b.available && b.reason && (
<span className="engine-matrix__reason" title={b.reason}>{b.reason}</span>
)}
{b.install_hint && (
<span className="engine-matrix__hint" title={b.install_hint}>
{b.install_hint}
</span>
)}
{b.last_error && (
<span className="engine-matrix__last-error" data-testid="last-error">
Last error: {b.last_error}
</span>
)}
</div>
{/* Install state */}
<div
role="cell"
className="engine-matrix__cell engine-matrix__cell--center"
style={{ width: 130 }}
title={b.available ? 'Installed and ready' : (b.reason || 'Not installed')}
>
{b.available
? <Badge tone="success" size="xs"><CheckCircle2 size={10} /> Available</Badge>
: <Badge tone="warn" size="xs"><AlertTriangle size={10} /> Unavailable</Badge>}
</div>
{/* GPU compat chips */}
<div role="cell" className="engine-matrix__cell engine-matrix__cell--gpu" style={{ width: 170 }}>
<div className="engine-matrix__chips">
{b.gpu_compat.map((g) => (
<span key={g} className={`engine-matrix__chip engine-matrix__chip--${g}`}>
{GPU_LABEL[g] || g.toUpperCase()}
</span>
))}
</div>
</div>
{/* Isolation mode */}
<div
role="cell"
className="engine-matrix__cell engine-matrix__cell--center"
style={{ width: 110 }}
title={b.isolation_mode === 'subprocess'
? 'Runs in its own subprocess + venv'
: 'Runs in the OmniVoice Python process'}
>
<Badge tone={ISOLATION_TONE[b.isolation_mode] || 'neutral'} size="xs">
{b.isolation_mode}
</Badge>
</div>
{/* Actions: Test engine + optional Use */}
<div
role="cell"
className="engine-matrix__cell engine-matrix__cell--actions"
style={{ width: 220 }}
>
<Button
size="sm"
variant="subtle"
onClick={() => testHealth(b.id)}
disabled={!!health?.inflight}
loading={!!health?.inflight}
leading={!health?.inflight && <Activity size={11} />}
aria-label={`Test ${b.display_name}`}
>
{health?.inflight ? 'Testing…' : 'Test engine'}
</Button>
{health && !health.inflight && (
<span
className={`engine-matrix__result engine-matrix__result--${health.ok ? 'ok' : 'fail'}`}
data-testid={`health-result-${b.id}`}
title={health.message}
>
{health.ok
? `${health.latency_ms} ms`
: `failed`}
</span>
)}
{onSelect && b.available && !isActive && (
<Button
size="sm"
variant="subtle"
onClick={() => onSelect(activeFamily, b.id)}
aria-label={`Use ${b.display_name}`}
>
Use
</Button>
)}
{/* 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]
&& (
<Button
size="sm"
variant="subtle"
onClick={() => setLicenseDialogFor(b.id)}
aria-label={`Review and accept ${b.display_name} license`}
>
Accept license
</Button>
)}
</div>
</div>
);
})}
{backends.length === 0 && (
<div className="engine-matrix__empty" role="row">
<span role="cell">No backends registered.</span>
</div>
)}
</div>
</Table>
</section>
);
}