feat(dashboard): voice bridge activity panel
This commit is contained in:
@@ -6,6 +6,7 @@ import { NpcPanel } from './NpcPanel.js';
|
||||
import { Timeline } from './Timeline.js';
|
||||
import { ControlPanel } from './ControlPanel.js';
|
||||
import { HintsAdaptivePanel } from './HintsAdaptivePanel.js';
|
||||
import { VoiceActivityPanel } from './VoiceActivityPanel.js';
|
||||
|
||||
export function ExpertDashboard() {
|
||||
const {
|
||||
@@ -67,6 +68,9 @@ export function ExpertDashboard() {
|
||||
{/* Hints adaptive (P4) */}
|
||||
<HintsAdaptivePanel />
|
||||
|
||||
{/* Voice bridge activity (P5) */}
|
||||
<VoiceActivityPanel />
|
||||
|
||||
{/* Controls */}
|
||||
<ControlPanel />
|
||||
</div>
|
||||
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* VoiceActivityPanel — live status of the voice-bridge (F5-TTS) on MacStudio.
|
||||
*
|
||||
* Surfaces three things to the GM:
|
||||
* 1. Health badge (ready / warmup / down) driven by /health/ready.
|
||||
* 2. Compact stats: warmup time, cache hit rate, count, size on disk.
|
||||
* 3. Activity pulse: any change in (cache.hits + cache.misses) between two
|
||||
* polls flashes a "voice TTS just synthesized" indicator for 1 s.
|
||||
*
|
||||
* The /voice/ws WebSocket is consumed by the firmware, not the dashboard, so
|
||||
* this panel is the only window the GM has into voice activity in real time.
|
||||
*/
|
||||
import { useEffect, useRef, useState } from 'react';
|
||||
import { useVoiceBridge } from '../hooks/useVoiceBridge.js';
|
||||
|
||||
type Status = 'ready' | 'warmup' | 'down';
|
||||
|
||||
interface Props {
|
||||
/** Override base URL (otherwise read from VITE_VOICE_BRIDGE_URL). */
|
||||
baseUrl?: string;
|
||||
/** Override poll interval in ms (default 2000). */
|
||||
pollMs?: number;
|
||||
}
|
||||
|
||||
function formatHostFromUrl(url: string): string {
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.host;
|
||||
} catch {
|
||||
return url;
|
||||
}
|
||||
}
|
||||
|
||||
function deriveStatus(args: {
|
||||
error: ReturnType<typeof useVoiceBridge>['error'];
|
||||
ready: boolean;
|
||||
f5_loaded: boolean;
|
||||
}): Status {
|
||||
if (args.error !== null && !args.ready) return 'down';
|
||||
if (args.ready && args.f5_loaded) return 'ready';
|
||||
return 'warmup';
|
||||
}
|
||||
|
||||
const STATUS_LABEL: Record<Status, string> = {
|
||||
ready: '\u{1F7E2} ready',
|
||||
warmup: '\u{1F7E1} warmup',
|
||||
down: '\u{1F534} down',
|
||||
};
|
||||
|
||||
const STATUS_TITLE: Record<Status, string> = {
|
||||
ready: 'F5-TTS chargé, prêt à synthétiser',
|
||||
warmup: 'F5-TTS en cours de chargement (warmup)',
|
||||
down: 'Voice-bridge injoignable ou erreur HTTP',
|
||||
};
|
||||
|
||||
const STATUS_CLASS: Record<Status, string> = {
|
||||
ready: 'bg-green-500/20 text-green-300',
|
||||
warmup: 'bg-yellow-500/20 text-yellow-300',
|
||||
down: 'bg-red-500/20 text-red-300',
|
||||
};
|
||||
|
||||
export function VoiceActivityPanel(props: Props) {
|
||||
const opts: Parameters<typeof useVoiceBridge>[0] = {};
|
||||
if (props.baseUrl !== undefined) opts.baseUrl = props.baseUrl;
|
||||
if (props.pollMs !== undefined) opts.pollMs = props.pollMs;
|
||||
const {
|
||||
ready,
|
||||
f5_loaded,
|
||||
warmup_ms,
|
||||
cache,
|
||||
latency_ms_health,
|
||||
error,
|
||||
loading,
|
||||
refetch,
|
||||
baseUrl,
|
||||
pollMs,
|
||||
} = useVoiceBridge(opts);
|
||||
|
||||
const status = deriveStatus({ error, ready, f5_loaded });
|
||||
|
||||
// Activity detection — when (hits + misses) increases between two polls, we
|
||||
// know the bridge synthesized at least one new utterance. We pulse a badge
|
||||
// for 1 s. We track the previous total in a ref so re-renders triggered by
|
||||
// unrelated state (e.g. latency update) don't accidentally re-trigger.
|
||||
const lastActivityTotalRef = useRef<number>(cache.hits + cache.misses);
|
||||
const [pulsing, setPulsing] = useState<boolean>(false);
|
||||
useEffect(() => {
|
||||
const total = cache.hits + cache.misses;
|
||||
if (total > lastActivityTotalRef.current) {
|
||||
setPulsing(true);
|
||||
const id = setTimeout(() => setPulsing(false), 1000);
|
||||
lastActivityTotalRef.current = total;
|
||||
return () => clearTimeout(id);
|
||||
}
|
||||
lastActivityTotalRef.current = total;
|
||||
}, [cache.hits, cache.misses]);
|
||||
|
||||
const hitRatePct = Math.round(cache.hit_rate_since_boot * 100);
|
||||
const warmupS = warmup_ms > 0 ? (warmup_ms / 1000).toFixed(1) : '—';
|
||||
|
||||
// One-line monitoring command users can copy-paste from the panel.
|
||||
const monitorCmd = `watch -n 2 'curl -s ${baseUrl}/tts/cache/stats | jq'`;
|
||||
|
||||
return (
|
||||
<section className="w-72 p-4 border-l border-white/10 overflow-y-auto">
|
||||
<header className="flex items-center justify-between mb-3 gap-2">
|
||||
<h2 className="text-xs font-semibold text-white/60 uppercase tracking-wide">
|
||||
{'\u{1F399}\u{FE0F} Voice Bridge'}
|
||||
</h2>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<span
|
||||
className={`text-[9px] px-1.5 py-0.5 rounded-full uppercase tracking-wide font-mono ${
|
||||
STATUS_CLASS[status]
|
||||
} ${pulsing ? 'animate-pulse ring-2 ring-cyan-300/60' : ''}`}
|
||||
title={STATUS_TITLE[status]}
|
||||
>
|
||||
{STATUS_LABEL[status]}
|
||||
</span>
|
||||
<button
|
||||
onClick={() => void refetch()}
|
||||
className="text-[10px] px-2 py-0.5 rounded-full bg-[#2c2c2e] hover:bg-[#3a3a3c] text-white/70"
|
||||
title="Recharger maintenant"
|
||||
>
|
||||
{loading ? '…' : '↻'}
|
||||
</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
<div className="text-[10px] text-white/40 mb-3 leading-relaxed">
|
||||
<div>
|
||||
{formatHostFromUrl(baseUrl)} · poll {Math.round(pollMs / 1000)}s
|
||||
{latency_ms_health > 0 && <span> · RTT {latency_ms_health}ms</span>}
|
||||
</div>
|
||||
<div>
|
||||
F5 :{' '}
|
||||
<span className={f5_loaded ? 'text-green-400' : 'text-orange-400'}>
|
||||
{f5_loaded ? 'chargé' : 'pas chargé'}
|
||||
</span>
|
||||
{warmup_ms > 0 && <span> · warmup {warmupS}s</span>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{error !== null && (
|
||||
<div className="mb-3 p-2 rounded-lg bg-red-500/10 border border-red-500/30 text-[11px] text-red-400">
|
||||
Voice-bridge HS : {error.kind === 'http' ? `HTTP ${error.status}` : 'réseau'} —{' '}
|
||||
{error.message}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div
|
||||
className={`rounded-xl border p-2.5 mb-3 transition-colors ${
|
||||
pulsing
|
||||
? 'border-cyan-300/60 bg-cyan-300/10'
|
||||
: 'border-white/10 bg-[#2c2c2e]'
|
||||
}`}
|
||||
aria-live="polite"
|
||||
>
|
||||
<div className="text-[10px] uppercase tracking-wide text-white/50 mb-1.5">
|
||||
Cache TTS
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-x-2 gap-y-1 text-[11px] text-white/70">
|
||||
<span>
|
||||
Entrées :{' '}
|
||||
<span className="text-white/95 font-mono">{cache.count}</span>
|
||||
</span>
|
||||
<span>
|
||||
Taille :{' '}
|
||||
<span className="text-white/95 font-mono">
|
||||
{cache.size_mb.toFixed(1)} Mo
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Hits :{' '}
|
||||
<span className="text-white/95 font-mono">{cache.hits}</span>
|
||||
</span>
|
||||
<span>
|
||||
Misses :{' '}
|
||||
<span className="text-white/95 font-mono">{cache.misses}</span>
|
||||
</span>
|
||||
<span className="col-span-2">
|
||||
Taux hit :{' '}
|
||||
<span
|
||||
className={
|
||||
hitRatePct >= 50
|
||||
? 'text-green-400 font-mono'
|
||||
: 'text-orange-300 font-mono'
|
||||
}
|
||||
>
|
||||
{hitRatePct}%
|
||||
</span>{' '}
|
||||
<span className="text-white/40">depuis boot</span>
|
||||
</span>
|
||||
</div>
|
||||
{pulsing && (
|
||||
<div className="mt-1.5 text-[10px] text-cyan-300 italic">
|
||||
voice TTS just synthesized
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-[10px] text-white/40 leading-relaxed">
|
||||
<div className="uppercase tracking-wide mb-1">Monitoring</div>
|
||||
<code className="block p-1.5 rounded bg-black/40 text-white/70 font-mono text-[10px] break-all whitespace-pre-wrap">
|
||||
{monitorCmd}
|
||||
</code>
|
||||
</div>
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,233 @@
|
||||
/**
|
||||
* @vitest-environment jsdom
|
||||
*
|
||||
* Tests for useVoiceBridge — exercises the parallel polling, the cleanup of
|
||||
* the interval/AbortController on unmount, and the parsing of the two
|
||||
* voice-bridge endpoints (/health/ready + /tts/cache/stats).
|
||||
*/
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
||||
import { act, useEffect } from 'react';
|
||||
import { createRoot, type Root } from 'react-dom/client';
|
||||
import type { VoiceBridgeReady, VoiceBridgeCacheStats } from '@zacus/shared';
|
||||
import { useVoiceBridge, type UseVoiceBridgeResult } from './useVoiceBridge.js';
|
||||
|
||||
// React 19 reads this flag to enable `act` semantics.
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
(globalThis as any).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Fixtures
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const READY_FIXTURE: VoiceBridgeReady = {
|
||||
ready: true,
|
||||
f5_loaded: true,
|
||||
warmup_ms: 8421,
|
||||
cache_size: 12,
|
||||
};
|
||||
|
||||
const CACHE_FIXTURE: VoiceBridgeCacheStats = {
|
||||
count: 12,
|
||||
size_mb: 3.4,
|
||||
hits: 50,
|
||||
misses: 8,
|
||||
hit_rate_since_boot: 0.86,
|
||||
};
|
||||
|
||||
function makeFetchMock(): ReturnType<typeof vi.fn> {
|
||||
return vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/health/ready')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => READY_FIXTURE,
|
||||
};
|
||||
}
|
||||
if (url.includes('/tts/cache/stats')) {
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => CACHE_FIXTURE,
|
||||
};
|
||||
}
|
||||
throw new Error(`unexpected fetch: ${url}`);
|
||||
});
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tiny capture-component (mirrors useHintsEngine.test.tsx)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function HookProbe({
|
||||
onResult,
|
||||
options,
|
||||
}: {
|
||||
onResult: (r: UseVoiceBridgeResult) => void;
|
||||
options: Parameters<typeof useVoiceBridge>[0];
|
||||
}) {
|
||||
const result = useVoiceBridge(options);
|
||||
useEffect(() => {
|
||||
onResult(result);
|
||||
});
|
||||
return null;
|
||||
}
|
||||
|
||||
let container: HTMLDivElement | null = null;
|
||||
let root: Root | null = null;
|
||||
|
||||
async function renderHook(
|
||||
options: Parameters<typeof useVoiceBridge>[0],
|
||||
): Promise<{ getResult: () => UseVoiceBridgeResult }> {
|
||||
container = document.createElement('div');
|
||||
document.body.appendChild(container);
|
||||
let last: UseVoiceBridgeResult | null = null;
|
||||
root = createRoot(container);
|
||||
await act(async () => {
|
||||
root!.render(<HookProbe options={options} onResult={(r) => (last = r)} />);
|
||||
});
|
||||
return {
|
||||
getResult: () => {
|
||||
if (!last) throw new Error('hook never produced a result');
|
||||
return last;
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function unmount(): Promise<void> {
|
||||
if (root) {
|
||||
await act(async () => {
|
||||
root!.unmount();
|
||||
});
|
||||
root = null;
|
||||
}
|
||||
if (container) {
|
||||
container.remove();
|
||||
container = null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Tests
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
describe('useVoiceBridge — polling + cleanup', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
await unmount();
|
||||
vi.useRealTimers();
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
it('polls /health/ready and /tts/cache/stats in parallel on each tick', async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const probe = await renderHook({
|
||||
baseUrl: 'http://test.local:8200',
|
||||
pollMs: 2_000,
|
||||
});
|
||||
|
||||
// Allow the initial refetch's promises to flush.
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const initialCalls = fetchMock.mock.calls.length;
|
||||
// First tick = both endpoints hit at least once.
|
||||
expect(initialCalls).toBeGreaterThanOrEqual(2);
|
||||
|
||||
const urls = fetchMock.mock.calls.map((c) => String(c[0]));
|
||||
expect(urls.some((u) => u.endsWith('/health/ready'))).toBe(true);
|
||||
expect(urls.some((u) => u.endsWith('/tts/cache/stats'))).toBe(true);
|
||||
|
||||
// Hook reflects the parsed payloads.
|
||||
const r = probe.getResult();
|
||||
expect(r.ready).toBe(true);
|
||||
expect(r.f5_loaded).toBe(true);
|
||||
expect(r.warmup_ms).toBe(8421);
|
||||
expect(r.cache.hits).toBe(50);
|
||||
expect(r.cache.hit_rate_since_boot).toBeCloseTo(0.86);
|
||||
|
||||
// Advance one poll interval — both endpoints should be hit again.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(2_000);
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBeGreaterThanOrEqual(initialCalls + 2);
|
||||
});
|
||||
|
||||
it('stops polling on unmount and aborts the in-flight request', async () => {
|
||||
const fetchMock = makeFetchMock();
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
const clearIntervalSpy = vi.spyOn(globalThis, 'clearInterval');
|
||||
|
||||
await renderHook({ baseUrl: 'http://test.local:8200', pollMs: 2_000 });
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const beforeUnmount = fetchMock.mock.calls.length;
|
||||
expect(beforeUnmount).toBeGreaterThanOrEqual(2);
|
||||
|
||||
await unmount();
|
||||
|
||||
expect(clearIntervalSpy).toHaveBeenCalled();
|
||||
|
||||
// After unmount, advancing timers must NOT trigger any new fetch.
|
||||
await act(async () => {
|
||||
vi.advanceTimersByTime(10_000);
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
expect(fetchMock.mock.calls.length).toBe(beforeUnmount);
|
||||
});
|
||||
|
||||
it('treats /health/ready 503 (warmup) as non-error and parses body', async () => {
|
||||
const warmupReady: VoiceBridgeReady = {
|
||||
ready: false,
|
||||
f5_loaded: false,
|
||||
warmup_ms: 0,
|
||||
cache_size: 0,
|
||||
};
|
||||
const fetchMock = vi.fn(async (input: RequestInfo | URL) => {
|
||||
const url = String(input);
|
||||
if (url.includes('/health/ready')) {
|
||||
return {
|
||||
ok: false,
|
||||
status: 503,
|
||||
json: async () => warmupReady,
|
||||
};
|
||||
}
|
||||
return {
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => CACHE_FIXTURE,
|
||||
};
|
||||
});
|
||||
vi.stubGlobal('fetch', fetchMock);
|
||||
|
||||
const probe = await renderHook({
|
||||
baseUrl: 'http://test.local:8200',
|
||||
pollMs: 2_000,
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
await Promise.resolve();
|
||||
});
|
||||
|
||||
const r = probe.getResult();
|
||||
expect(r.ready).toBe(false);
|
||||
expect(r.f5_loaded).toBe(false);
|
||||
expect(r.error).toBeNull(); // 503 during warmup is expected, not an error
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,190 @@
|
||||
/**
|
||||
* useVoiceBridge — REST polling hook against the voice-bridge FastAPI server
|
||||
* (tools/voice/bridge.py running on the MacStudio).
|
||||
*
|
||||
* Polls two endpoints in parallel on a fixed interval:
|
||||
* - GET /health/ready → F5-TTS warmup status (ready / loaded / warmup_ms)
|
||||
* - GET /tts/cache/stats → cache (count, size_mb, hits, misses, hit_rate)
|
||||
*
|
||||
* The dashboard cannot subscribe to the WebSocket /voice/ws because that
|
||||
* channel is reserved for firmware streaming. Polling cache stats is the
|
||||
* cheapest way to surface "voice TTS just synthesized" activity to the GM:
|
||||
* any change in (hits + misses) between two polls is treated as new TTS work.
|
||||
*
|
||||
* Configuration (Vite env, all optional):
|
||||
* VITE_VOICE_BRIDGE_URL base URL (default 100.116.92.12:8200)
|
||||
* VITE_VOICE_BRIDGE_POLL_MS poll interval in ms (default 2000)
|
||||
*/
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from 'react';
|
||||
import {
|
||||
VOICE_BRIDGE_DEFAULT_BASE_URL,
|
||||
VOICE_BRIDGE_DEFAULT_POLL_MS,
|
||||
type VoiceBridgeReady,
|
||||
type VoiceBridgeCacheStats,
|
||||
} from '@zacus/shared';
|
||||
|
||||
export type VoiceBridgeError =
|
||||
| { kind: 'network'; message: string }
|
||||
| { kind: 'http'; status: number; message: string };
|
||||
|
||||
export interface UseVoiceBridgeOptions {
|
||||
/** Override base URL (otherwise read from VITE_VOICE_BRIDGE_URL). */
|
||||
baseUrl?: string;
|
||||
/** Override poll interval in ms (otherwise read from VITE_VOICE_BRIDGE_POLL_MS). */
|
||||
pollMs?: number;
|
||||
/** Disable polling (manual mode — call refetch()). */
|
||||
paused?: boolean;
|
||||
}
|
||||
|
||||
export interface UseVoiceBridgeResult {
|
||||
ready: boolean;
|
||||
f5_loaded: boolean;
|
||||
warmup_ms: number;
|
||||
cache: VoiceBridgeCacheStats;
|
||||
/** Round-trip latency for the last /health/ready call, in ms. */
|
||||
latency_ms_health: number;
|
||||
error: VoiceBridgeError | null;
|
||||
loading: boolean;
|
||||
refetch: () => Promise<void>;
|
||||
baseUrl: string;
|
||||
pollMs: number;
|
||||
}
|
||||
|
||||
interface ImportMetaEnvLike {
|
||||
VITE_VOICE_BRIDGE_URL?: string;
|
||||
VITE_VOICE_BRIDGE_POLL_MS?: string;
|
||||
}
|
||||
|
||||
function readEnv(): ImportMetaEnvLike {
|
||||
// import.meta.env is replaced at build time by Vite. In tests / Node it may
|
||||
// not exist — fall back to an empty object.
|
||||
try {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const env = (import.meta as any)?.env;
|
||||
return (env as ImportMetaEnvLike) ?? {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
const EMPTY_CACHE: VoiceBridgeCacheStats = {
|
||||
count: 0,
|
||||
size_mb: 0,
|
||||
hits: 0,
|
||||
misses: 0,
|
||||
hit_rate_since_boot: 0,
|
||||
};
|
||||
|
||||
export function useVoiceBridge(opts: UseVoiceBridgeOptions = {}): UseVoiceBridgeResult {
|
||||
const env = useMemo(() => readEnv(), []);
|
||||
const baseUrl = (opts.baseUrl ?? env.VITE_VOICE_BRIDGE_URL ?? VOICE_BRIDGE_DEFAULT_BASE_URL).replace(
|
||||
/\/+$/,
|
||||
'',
|
||||
);
|
||||
const pollMs =
|
||||
opts.pollMs ?? Number(env.VITE_VOICE_BRIDGE_POLL_MS ?? VOICE_BRIDGE_DEFAULT_POLL_MS);
|
||||
|
||||
const [ready, setReady] = useState<boolean>(false);
|
||||
const [f5Loaded, setF5Loaded] = useState<boolean>(false);
|
||||
const [warmupMs, setWarmupMs] = useState<number>(0);
|
||||
const [cache, setCache] = useState<VoiceBridgeCacheStats>(EMPTY_CACHE);
|
||||
const [latencyMsHealth, setLatencyMsHealth] = useState<number>(0);
|
||||
const [error, setError] = useState<VoiceBridgeError | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(false);
|
||||
|
||||
const abortRef = useRef<AbortController | null>(null);
|
||||
|
||||
const refetch = useCallback(async () => {
|
||||
abortRef.current?.abort();
|
||||
const ctrl = new AbortController();
|
||||
abortRef.current = ctrl;
|
||||
setLoading(true);
|
||||
|
||||
const t0 = Date.now();
|
||||
try {
|
||||
// Fire both probes in parallel — they target the same FastAPI process,
|
||||
// so latency is dominated by network RTT. We treat /health/ready as the
|
||||
// primary error source (503 is expected during F5 warmup and is NOT an
|
||||
// error on its own — we still parse the body).
|
||||
const [readyResp, cacheResp] = await Promise.all([
|
||||
fetch(`${baseUrl}/health/ready`, { signal: ctrl.signal }),
|
||||
fetch(`${baseUrl}/tts/cache/stats`, { signal: ctrl.signal }),
|
||||
]);
|
||||
|
||||
setLatencyMsHealth(Date.now() - t0);
|
||||
|
||||
// /health/ready returns 200 when ready, 503 during warmup. Both carry
|
||||
// the same JSON body — we always parse it.
|
||||
if (readyResp.status !== 200 && readyResp.status !== 503) {
|
||||
setError({
|
||||
kind: 'http',
|
||||
status: readyResp.status,
|
||||
message: `GET /health/ready → ${readyResp.status}`,
|
||||
});
|
||||
setReady(false);
|
||||
} else {
|
||||
const data = (await readyResp.json()) as VoiceBridgeReady;
|
||||
setReady(Boolean(data.ready));
|
||||
setF5Loaded(Boolean(data.f5_loaded));
|
||||
setWarmupMs(typeof data.warmup_ms === 'number' ? data.warmup_ms : 0);
|
||||
if (readyResp.status === 200 || readyResp.status === 503) setError(null);
|
||||
}
|
||||
|
||||
if (!cacheResp.ok) {
|
||||
setError({
|
||||
kind: 'http',
|
||||
status: cacheResp.status,
|
||||
message: `GET /tts/cache/stats → ${cacheResp.status}`,
|
||||
});
|
||||
} else {
|
||||
const stats = (await cacheResp.json()) as VoiceBridgeCacheStats;
|
||||
setCache({
|
||||
count: typeof stats.count === 'number' ? stats.count : 0,
|
||||
size_mb: typeof stats.size_mb === 'number' ? stats.size_mb : 0,
|
||||
hits: typeof stats.hits === 'number' ? stats.hits : 0,
|
||||
misses: typeof stats.misses === 'number' ? stats.misses : 0,
|
||||
hit_rate_since_boot:
|
||||
typeof stats.hit_rate_since_boot === 'number' ? stats.hit_rate_since_boot : 0,
|
||||
});
|
||||
}
|
||||
} catch (err) {
|
||||
// AbortError is expected during teardown — swallow.
|
||||
if (err instanceof DOMException && err.name === 'AbortError') return;
|
||||
const message = err instanceof Error ? err.message : String(err);
|
||||
setError({ kind: 'network', message });
|
||||
setReady(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [baseUrl]);
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// Polling effect — fires immediately, then every `pollMs`. Re-arms whenever
|
||||
// baseUrl, pollMs, or paused changes.
|
||||
// --------------------------------------------------------------------------
|
||||
useEffect(() => {
|
||||
if (opts.paused) return;
|
||||
void refetch();
|
||||
if (pollMs <= 0) return;
|
||||
const id = setInterval(() => {
|
||||
void refetch();
|
||||
}, pollMs);
|
||||
return () => {
|
||||
clearInterval(id);
|
||||
abortRef.current?.abort();
|
||||
};
|
||||
}, [opts.paused, pollMs, refetch]);
|
||||
|
||||
return {
|
||||
ready,
|
||||
f5_loaded: f5Loaded,
|
||||
warmup_ms: warmupMs,
|
||||
cache,
|
||||
latency_ms_health: latencyMsHealth,
|
||||
error,
|
||||
loading,
|
||||
refetch,
|
||||
baseUrl,
|
||||
pollMs,
|
||||
};
|
||||
}
|
||||
@@ -28,3 +28,8 @@ export const BOX3_WS_RECONNECT_MS = 3000;
|
||||
export const HINTS_DEFAULT_BASE_URL = 'http://localhost:8311';
|
||||
export const HINTS_DEFAULT_POLL_MS = 5000;
|
||||
export const HINTS_GROUP_PROFILES = ['TECH', 'NON_TECH', 'MIXED', 'BOTH'] as const;
|
||||
|
||||
// Voice-bridge REST defaults — overridden via VITE_VOICE_BRIDGE_URL / VITE_VOICE_BRIDGE_POLL_MS
|
||||
// 100.116.92.12:8200 = MacStudio Tailscale IP, where the F5-TTS bridge runs.
|
||||
export const VOICE_BRIDGE_DEFAULT_BASE_URL = 'http://100.116.92.12:8200';
|
||||
export const VOICE_BRIDGE_DEFAULT_POLL_MS = 2000;
|
||||
|
||||
@@ -248,6 +248,27 @@ export interface HintAskResponse {
|
||||
group_profile_used: HintsGroupProfile | null;
|
||||
}
|
||||
|
||||
// ============================================================
|
||||
// VOICE BRIDGE TYPES (mirror tools/voice/bridge.py FastAPI surface)
|
||||
// ============================================================
|
||||
|
||||
/** GET /health/ready envelope. F5-TTS warmup status. */
|
||||
export interface VoiceBridgeReady {
|
||||
ready: boolean;
|
||||
f5_loaded: boolean;
|
||||
warmup_ms: number;
|
||||
cache_size: number;
|
||||
}
|
||||
|
||||
/** GET /tts/cache/stats envelope. */
|
||||
export interface VoiceBridgeCacheStats {
|
||||
count: number;
|
||||
size_mb: number;
|
||||
hits: number;
|
||||
misses: number;
|
||||
hit_rate_since_boot: number;
|
||||
}
|
||||
|
||||
export interface ScenarioYaml {
|
||||
id: string;
|
||||
version: string;
|
||||
|
||||
Reference in New Issue
Block a user