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:
parent
3673fa03a1
commit
519076771b
4 changed files with 102 additions and 12 deletions
|
|
@ -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";
|
||||
|
|
|
|||
|
|
@ -430,6 +430,7 @@ export function OnboardingWizard() {
|
|||
setStep(5);
|
||||
}}
|
||||
onSkip={() => setStep(5)}
|
||||
voiceCapability={hardwareInfo?.voiceCapability}
|
||||
/>
|
||||
|
||||
<Button
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue