feat(39-02): VoiceStep hardware-aware UI with conditional enable/skip

- Add VoiceCapability interface to ui/src/api/hardware.ts
- Export VoiceCapability type from useHardwareInfo.ts
- VoiceStep accepts voiceCapability prop, renders conditionally
- Insufficient hardware: shows capability note with skip-only button
- Binaries present: shows green checkmarks next to STT/TTS labels
- Missing binaries on sufficient hardware: shows install note, dimmed Enable
- NexusOnboardingWizard passes voiceCapability from hardware probe to VoiceStep
This commit is contained in:
Nexus Dev 2026-04-04 03:33:10 +00:00
parent 3673fa03a1
commit 519076771b
4 changed files with 102 additions and 12 deletions

View file

@ -3,6 +3,12 @@ import { api } from "./client";
export type HardwareTier = "gpu" | "apple_silicon" | "cpu_only";
export interface VoiceCapability {
whisperAvailable: boolean;
piperAvailable: boolean;
voiceTierSufficient: boolean;
}
export interface HardwareInfo {
totalGb: number;
freeGb: number;
@ -13,6 +19,7 @@ export interface HardwareInfo {
unifiedMemory: boolean;
hardwareTier: HardwareTier;
cpuModel: string | null;
voiceCapability?: VoiceCapability;
}
export type NexusMode = "personal_ai" | "project_builder" | "both";

View file

@ -430,6 +430,7 @@ export function OnboardingWizard() {
setStep(5);
}}
onSkip={() => setStep(5)}
voiceCapability={hardwareInfo?.voiceCapability}
/>
<Button

View file

@ -1,13 +1,15 @@
import { useEffect, useState } from "react";
import { Mic, Volume2 } from "lucide-react";
import { Mic, Volume2, CheckCircle2, AlertTriangle, Info } from "lucide-react";
import { Button } from "@/components/ui/button";
import type { VoiceCapability } from "../../hooks/useHardwareInfo";
interface VoiceStepProps {
onEnable: () => void;
onSkip: () => void;
voiceCapability?: VoiceCapability;
}
export function VoiceStep({ onEnable, onSkip }: VoiceStepProps) {
export function VoiceStep({ onEnable, onSkip, voiceCapability }: VoiceStepProps) {
const [micAvailable, setMicAvailable] = useState<boolean | null>(null);
useEffect(() => {
@ -16,36 +18,114 @@ export function VoiceStep({ onEnable, onSkip }: VoiceStepProps) {
.catch(() => setMicAvailable(false));
}, []);
// Determine STT status label
function whisperStatusLabel(): string {
if (!voiceCapability) {
// Fall back to mic-only check
if (micAvailable === false) return "No microphone detected — unavailable";
if (micAvailable === true) return "Microphone detected — speak to your assistant";
return "Checking microphone...";
}
if (voiceCapability.whisperAvailable) return "Whisper detected — speech recognition ready";
return "Whisper not found — install whisper-cpp for voice input";
}
function whisperStatusIcon() {
if (!voiceCapability) return null;
if (voiceCapability.whisperAvailable) {
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
}
return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />;
}
// Determine TTS status label
function piperStatusLabel(): string {
if (!voiceCapability) {
return "Hear responses read aloud. Runs entirely on your device — no server needed.";
}
if (voiceCapability.piperAvailable) return "Piper detected — text-to-speech ready";
return "Piper not found — install piper for voice output";
}
function piperStatusIcon() {
if (!voiceCapability) return null;
if (voiceCapability.piperAvailable) {
return <CheckCircle2 className="h-4 w-4 text-green-500 shrink-0" />;
}
return <AlertTriangle className="h-4 w-4 text-amber-500 shrink-0" />;
}
// Insufficient hardware: show note + skip only
if (voiceCapability && !voiceCapability.voiceTierSufficient) {
return (
<div className="flex flex-col gap-4">
<div className="flex items-start gap-3 rounded-lg border border-amber-200 bg-amber-50 dark:border-amber-800 dark:bg-amber-950/20 p-3">
<Info className="h-5 w-5 text-amber-500 shrink-0 mt-0.5" />
<div>
<p className="text-sm font-medium text-amber-800 dark:text-amber-300">Hardware may not support voice</p>
<p className="text-xs text-amber-700 dark:text-amber-400 mt-1">
Voice features require at least 4GB free RAM. Your system currently has insufficient free memory for local voice processing.
</p>
</div>
</div>
<div className="flex flex-col gap-2">
<Button variant="ghost" onClick={onSkip} className="w-full">
Skip voice setup
</Button>
</div>
</div>
);
}
// Sufficient hardware or unknown — show full UI
const neitherBinaryFound =
voiceCapability &&
!voiceCapability.whisperAvailable &&
!voiceCapability.piperAvailable;
return (
<div className="flex flex-col gap-4">
{/* Install note when tier is sufficient but binaries are missing */}
{neitherBinaryFound && (
<div className="flex items-start gap-3 rounded-lg border border-blue-200 bg-blue-50 dark:border-blue-800 dark:bg-blue-950/20 p-3">
<Info className="h-5 w-5 text-blue-500 shrink-0 mt-0.5" />
<p className="text-xs text-blue-700 dark:text-blue-400">
Install whisper-cpp and piper for local voice features. You can enable voice now and configure binaries later.
</p>
</div>
)}
<div className="flex flex-col gap-3">
<div className="flex items-center gap-3 rounded-lg border p-3">
<Mic className="h-5 w-5 text-primary shrink-0" />
<div>
<div className="flex-1">
<p className="text-sm font-medium">Speech-to-Text (Whisper)</p>
<p className="text-xs text-muted-foreground">
{micAvailable === false
? "No microphone detected — unavailable"
: micAvailable === true
? "Microphone detected — speak to your assistant"
: "Checking microphone..."}
{whisperStatusLabel()}
</p>
</div>
{whisperStatusIcon()}
</div>
<div className="flex items-center gap-3 rounded-lg border p-3">
<Volume2 className="h-5 w-5 text-primary shrink-0" />
<div>
<div className="flex-1">
<p className="text-sm font-medium">Text-to-Speech (Piper)</p>
<p className="text-xs text-muted-foreground">
Hear responses read aloud. Runs entirely on your device no server needed.
{piperStatusLabel()}
</p>
</div>
{piperStatusIcon()}
</div>
</div>
<div className="flex flex-col gap-2">
<Button onClick={onEnable} className="w-full">
<Button
onClick={onEnable}
className="w-full"
variant={neitherBinaryFound ? "outline" : "default"}
>
Enable voice
</Button>
<Button variant="ghost" onClick={onSkip} className="w-full">

View file

@ -1,8 +1,10 @@
// [nexus] React Query hook for hardware detection data
import { useQuery } from "@tanstack/react-query";
import { fetchHardwareInfo, type HardwareInfo } from "../api/hardware";
import { fetchHardwareInfo, type HardwareInfo, type VoiceCapability } from "../api/hardware";
import { queryKeys } from "../lib/queryKeys";
export type { VoiceCapability };
export function useHardwareInfo(enabled = true) {
return useQuery<HardwareInfo>({
queryKey: queryKeys.hardware.info,