mirror of
https://github.com/jamiepine/voicebox.git
synced 2026-09-29 15:15:27 -07:00
feat(capture): gate global hotkey on dictation readiness checklist
Stops the "stuck pill" failure where pressing the chord with missing STT/LLM models triggers a recording that has nowhere to land. The hotkey now stays disarmed until every gate (models downloaded, Input Monitoring + Accessibility granted) is green; the empty-state checklist in CapturesTab surfaces each unmet gate with a one-click action and auto-arms the chord once everything turns green. Co-Authored-By: Claude Opus 4.7 (1M context) <[email protected]>
This commit is contained in:
co-authored by
Claude Opus 4.7
parent
868a40fb7e
commit
85a3e1363f
@@ -21,6 +21,7 @@ import {
|
|||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useEffect, useMemo, useRef, useState } from 'react';
|
import { useEffect, useMemo, useRef, useState } from 'react';
|
||||||
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
import { CapturePill } from '@/components/CapturePill/CapturePill';
|
||||||
|
import { DictationReadinessChecklist } from '@/components/CapturesTab/DictationReadinessChecklist';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -44,6 +45,7 @@ import type {
|
|||||||
import type { LanguageCode } from '@/lib/constants/languages';
|
import type { LanguageCode } from '@/lib/constants/languages';
|
||||||
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
import { BOTTOM_SAFE_AREA_PADDING } from '@/lib/constants/ui';
|
||||||
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
|
import { useCaptureRecordingSession } from '@/lib/hooks/useCaptureRecordingSession';
|
||||||
|
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
import { displayLabelForKey, modifierSideHint } from '@/lib/utils/keyCodes';
|
||||||
@@ -188,6 +190,7 @@ export function CapturesTab() {
|
|||||||
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
|
const hotkeyEnabled = captureSettings?.hotkey_enabled ?? false;
|
||||||
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
|
const pushToTalkKeys = captureSettings?.chord_push_to_talk_keys ?? [];
|
||||||
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
|
const toggleToTalkKeys = captureSettings?.chord_toggle_to_talk_keys ?? [];
|
||||||
|
const readiness = useDictationReadiness();
|
||||||
|
|
||||||
const session = useCaptureRecordingSession({
|
const session = useCaptureRecordingSession({
|
||||||
onCaptureCreated: (capture) => setSelectedId(capture.id),
|
onCaptureCreated: (capture) => setSelectedId(capture.id),
|
||||||
@@ -776,6 +779,8 @@ export function CapturesTab() {
|
|||||||
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
<Captions className="h-10 w-10 mx-auto opacity-40" />
|
||||||
<p className="text-sm">Pick a capture to see the transcript.</p>
|
<p className="text-sm">Pick a capture to see the transcript.</p>
|
||||||
</div>
|
</div>
|
||||||
|
) : hotkeyEnabled && !readiness.allReady ? (
|
||||||
|
<DictationReadinessChecklist readiness={readiness} />
|
||||||
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
|
) : hotkeyEnabled && (pushToTalkKeys.length || toggleToTalkKeys.length) ? (
|
||||||
<div className="max-w-sm mx-auto text-center space-y-5">
|
<div className="max-w-sm mx-auto text-center space-y-5">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
|
|||||||
@@ -0,0 +1,223 @@
|
|||||||
|
import { useMutation, useQueryClient } from '@tanstack/react-query';
|
||||||
|
import {
|
||||||
|
Accessibility,
|
||||||
|
CheckCircle2,
|
||||||
|
Circle,
|
||||||
|
Cpu,
|
||||||
|
Download,
|
||||||
|
ExternalLink,
|
||||||
|
Keyboard,
|
||||||
|
Loader2,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { useState } from 'react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { DictationReadiness, ReadinessGate } from '@/lib/hooks/useDictationReadiness';
|
||||||
|
import { cn } from '@/lib/utils/cn';
|
||||||
|
|
||||||
|
interface RowProps {
|
||||||
|
icon: React.ReactNode;
|
||||||
|
title: string;
|
||||||
|
description: string;
|
||||||
|
ready: boolean;
|
||||||
|
action?: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ChecklistRow({ icon, title, description, ready, action }: RowProps) {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
className={cn(
|
||||||
|
'flex items-start gap-3 rounded-lg border p-3.5 transition-colors',
|
||||||
|
ready ? 'border-emerald-500/20 bg-emerald-500/5' : 'border-border bg-muted/20',
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<div className="mt-0.5 shrink-0">
|
||||||
|
{ready ? (
|
||||||
|
<CheckCircle2 className="h-5 w-5 text-emerald-500" />
|
||||||
|
) : (
|
||||||
|
<Circle className="h-5 w-5 text-muted-foreground/50" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 min-w-0 space-y-1">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">{icon}</span>
|
||||||
|
<p className="text-sm font-medium text-foreground">{title}</p>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-muted-foreground leading-relaxed">{description}</p>
|
||||||
|
{!ready && action ? <div className="pt-1.5">{action}</div> : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Renders one row per dictation-readiness gate. Each unmet gate gets an
|
||||||
|
* inline action — Download for missing models, Open Settings for missing
|
||||||
|
* TCC permissions — so the user can resolve everything without leaving
|
||||||
|
* Captures.
|
||||||
|
*
|
||||||
|
* The chord stays disarmed until every row is green; this is what stops the
|
||||||
|
* "stuck pill" failure mode of pressing the chord with a missing model.
|
||||||
|
*/
|
||||||
|
export function DictationReadinessChecklist({ readiness }: { readiness: DictationReadiness }) {
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const [downloading, setDownloading] = useState<Set<ReadinessGate>>(new Set());
|
||||||
|
|
||||||
|
const downloadMutation = useMutation({
|
||||||
|
mutationFn: async ({ modelName }: { gate: ReadinessGate; modelName: string }) =>
|
||||||
|
apiClient.triggerModelDownload(modelName),
|
||||||
|
onSuccess: (_data, vars) => {
|
||||||
|
// Bump model status + readiness so the checklist row flips green as
|
||||||
|
// soon as the cache is populated. Keep the gate in `downloading` until
|
||||||
|
// readiness reports `ready: true` to avoid a flash of "Download" on
|
||||||
|
// post-completion polls.
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['modelStatus'] });
|
||||||
|
queryClient.invalidateQueries({ queryKey: ['capture-readiness'] });
|
||||||
|
toast({
|
||||||
|
title: 'Download started',
|
||||||
|
description: `${vars.gate === 'stt' ? readiness.stt?.display_name : readiness.llm?.display_name} is downloading. The shortcut will arm itself when it finishes.`,
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onError: (err: Error, vars) => {
|
||||||
|
setDownloading((prev) => {
|
||||||
|
const next = new Set(prev);
|
||||||
|
next.delete(vars.gate);
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
toast({
|
||||||
|
title: 'Download failed',
|
||||||
|
description: err.message,
|
||||||
|
variant: 'destructive',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
const startDownload = (gate: ReadinessGate, modelName: string) => {
|
||||||
|
setDownloading((prev) => new Set(prev).add(gate));
|
||||||
|
downloadMutation.mutate({ gate, modelName });
|
||||||
|
};
|
||||||
|
|
||||||
|
// Once readiness flips ready=true for a gate, drop it from `downloading`
|
||||||
|
// so subsequent reopens show the green check, not "Downloading…".
|
||||||
|
const isDownloading = (gate: ReadinessGate, ready: boolean) => !ready && downloading.has(gate);
|
||||||
|
|
||||||
|
const sttSize =
|
||||||
|
readiness.stt?.size_mb != null ? `${(readiness.stt.size_mb / 1000).toFixed(1)} GB` : null;
|
||||||
|
const llmSize =
|
||||||
|
readiness.llm?.size_mb != null ? `${(readiness.llm.size_mb / 1000).toFixed(1)} GB` : null;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="w-full max-w-md mx-auto space-y-2.5">
|
||||||
|
<div className="text-center mb-5 space-y-1">
|
||||||
|
<h2 className="text-base font-semibold text-foreground">
|
||||||
|
A few things before you can dictate
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
The shortcut stays off until everything below is ready.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{readiness.stt && (
|
||||||
|
<ChecklistRow
|
||||||
|
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||||
|
title={`${readiness.stt.display_name} (speech-to-text)`}
|
||||||
|
description={
|
||||||
|
readiness.stt.ready
|
||||||
|
? 'Model downloaded.'
|
||||||
|
: `Needed to transcribe your audio${sttSize ? ` · ${sttSize}` : ''}.`
|
||||||
|
}
|
||||||
|
ready={readiness.stt.ready}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startDownload('stt', readiness.stt!.model_name)}
|
||||||
|
disabled={isDownloading('stt', readiness.stt.ready)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isDownloading('stt', readiness.stt.ready) ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Downloading…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{readiness.llm && (
|
||||||
|
<ChecklistRow
|
||||||
|
icon={<Cpu className="h-3.5 w-3.5" />}
|
||||||
|
title={`${readiness.llm.display_name} (refinement)`}
|
||||||
|
description={
|
||||||
|
readiness.llm.ready
|
||||||
|
? 'Model downloaded.'
|
||||||
|
: `Cleans up the raw transcript before paste${llmSize ? ` · ${llmSize}` : ''}.`
|
||||||
|
}
|
||||||
|
ready={readiness.llm.ready}
|
||||||
|
action={
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
onClick={() => startDownload('llm', readiness.llm!.model_name)}
|
||||||
|
disabled={isDownloading('llm', readiness.llm.ready)}
|
||||||
|
className="gap-1.5"
|
||||||
|
>
|
||||||
|
{isDownloading('llm', readiness.llm.ready) ? (
|
||||||
|
<>
|
||||||
|
<Loader2 className="h-3.5 w-3.5 animate-spin" />
|
||||||
|
Downloading…
|
||||||
|
</>
|
||||||
|
) : (
|
||||||
|
<>
|
||||||
|
<Download className="h-3.5 w-3.5" />
|
||||||
|
Download
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<ChecklistRow
|
||||||
|
icon={<Keyboard className="h-3.5 w-3.5" />}
|
||||||
|
title="Input Monitoring permission"
|
||||||
|
description={
|
||||||
|
readiness.inputMonitoring
|
||||||
|
? 'macOS allows Voicebox to detect your global shortcut.'
|
||||||
|
: 'macOS needs to allow Voicebox to detect the global shortcut.'
|
||||||
|
}
|
||||||
|
ready={readiness.inputMonitoring}
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={readiness.openInputMonitoringSettings} className="gap-1.5">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
Open Settings
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
|
||||||
|
<ChecklistRow
|
||||||
|
icon={<Accessibility className="h-3.5 w-3.5" />}
|
||||||
|
title="Accessibility permission"
|
||||||
|
description={
|
||||||
|
readiness.accessibility
|
||||||
|
? 'Voicebox can paste transcriptions into other apps.'
|
||||||
|
: 'Required so transcriptions can paste into the focused app.'
|
||||||
|
}
|
||||||
|
ready={readiness.accessibility}
|
||||||
|
action={
|
||||||
|
<Button size="sm" onClick={readiness.openAccessibilitySettings} className="gap-1.5">
|
||||||
|
<ExternalLink className="h-3.5 w-3.5" />
|
||||||
|
Open Settings
|
||||||
|
</Button>
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -21,6 +21,8 @@ import {
|
|||||||
SelectValue,
|
SelectValue,
|
||||||
} from '@/components/ui/select';
|
} from '@/components/ui/select';
|
||||||
import { Toggle } from '@/components/ui/toggle';
|
import { Toggle } from '@/components/ui/toggle';
|
||||||
|
import { useToast } from '@/components/ui/use-toast';
|
||||||
|
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||||
import { useProfiles } from '@/lib/hooks/useProfiles';
|
import { useProfiles } from '@/lib/hooks/useProfiles';
|
||||||
import { cn } from '@/lib/utils/cn';
|
import { cn } from '@/lib/utils/cn';
|
||||||
@@ -132,6 +134,8 @@ function HotkeyPillPreview({ enabled }: { enabled: boolean }) {
|
|||||||
export function CapturesPage() {
|
export function CapturesPage() {
|
||||||
const { settings, update } = useCaptureSettings();
|
const { settings, update } = useCaptureSettings();
|
||||||
const { data: profiles } = useProfiles();
|
const { data: profiles } = useProfiles();
|
||||||
|
const { toast } = useToast();
|
||||||
|
const readiness = useDictationReadiness();
|
||||||
const sttModel = settings?.stt_model ?? 'turbo';
|
const sttModel = settings?.stt_model ?? 'turbo';
|
||||||
const language = settings?.language ?? 'auto';
|
const language = settings?.language ?? 'auto';
|
||||||
const autoRefine = settings?.auto_refine ?? true;
|
const autoRefine = settings?.auto_refine ?? true;
|
||||||
@@ -172,7 +176,31 @@ export function CapturesPage() {
|
|||||||
<Toggle
|
<Toggle
|
||||||
id="hotkeyEnabled"
|
id="hotkeyEnabled"
|
||||||
checked={hotkeyEnabled}
|
checked={hotkeyEnabled}
|
||||||
onCheckedChange={(v) => update({ hotkey_enabled: v })}
|
onCheckedChange={(v) => {
|
||||||
|
update({ hotkey_enabled: v });
|
||||||
|
// Surface model-readiness blocks at the toggle. The
|
||||||
|
// InputMonitoringNotice below already covers TCC, but
|
||||||
|
// missing models would otherwise be invisible from this
|
||||||
|
// page — the user toggles on, presses the chord, and
|
||||||
|
// nothing happens because useChordSync gates on readiness.
|
||||||
|
if (!v) return;
|
||||||
|
const missingModels = readiness.missing.filter(
|
||||||
|
(g) => g === 'stt' || g === 'llm',
|
||||||
|
);
|
||||||
|
if (missingModels.length === 0) return;
|
||||||
|
const names = [
|
||||||
|
missingModels.includes('stt') ? readiness.stt?.display_name : null,
|
||||||
|
missingModels.includes('llm') ? readiness.llm?.display_name : null,
|
||||||
|
]
|
||||||
|
.filter(Boolean)
|
||||||
|
.join(' and ');
|
||||||
|
toast({
|
||||||
|
title: 'Shortcut on, but not yet armed',
|
||||||
|
description: `${names} still need${
|
||||||
|
missingModels.length === 1 ? 's' : ''
|
||||||
|
} to download. Open the Captures tab to start.`,
|
||||||
|
});
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
}
|
}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -38,6 +38,7 @@ import type {
|
|||||||
CaptureListResponse,
|
CaptureListResponse,
|
||||||
CaptureResponse,
|
CaptureResponse,
|
||||||
CaptureCreateResponse,
|
CaptureCreateResponse,
|
||||||
|
CaptureReadinessResponse,
|
||||||
CaptureRefineRequest,
|
CaptureRefineRequest,
|
||||||
CaptureRetranscribeRequest,
|
CaptureRetranscribeRequest,
|
||||||
CaptureSettings,
|
CaptureSettings,
|
||||||
@@ -486,6 +487,10 @@ class ApiClient {
|
|||||||
return this.request<CaptureSettings>('/settings/captures');
|
return this.request<CaptureSettings>('/settings/captures');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async getCaptureReadiness(): Promise<CaptureReadinessResponse> {
|
||||||
|
return this.request<CaptureReadinessResponse>('/capture/readiness');
|
||||||
|
}
|
||||||
|
|
||||||
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
|
async updateCaptureSettings(patch: CaptureSettingsUpdate): Promise<CaptureSettings> {
|
||||||
return this.request<CaptureSettings>('/settings/captures', {
|
return this.request<CaptureSettings>('/settings/captures', {
|
||||||
method: 'PUT',
|
method: 'PUT',
|
||||||
|
|||||||
@@ -219,6 +219,26 @@ export interface CaptureSettings {
|
|||||||
|
|
||||||
export type CaptureSettingsUpdate = Partial<CaptureSettings>;
|
export type CaptureSettingsUpdate = Partial<CaptureSettings>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* One row in the dictation readiness checklist. ``model_name`` is the
|
||||||
|
* canonical id understood by ``POST /models/download`` so the UI can wire a
|
||||||
|
* one-click "Download" button without a second lookup.
|
||||||
|
*/
|
||||||
|
export interface ModelReadiness {
|
||||||
|
ready: boolean;
|
||||||
|
model_name: string;
|
||||||
|
display_name: string;
|
||||||
|
size: string;
|
||||||
|
size_mb?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Backend half of the dictation readiness check. The frontend combines this
|
||||||
|
* with TCC permission state into the full checklist used by useDictationReadiness. */
|
||||||
|
export interface CaptureReadinessResponse {
|
||||||
|
stt: ModelReadiness;
|
||||||
|
llm: ModelReadiness;
|
||||||
|
}
|
||||||
|
|
||||||
export interface GenerationSettings {
|
export interface GenerationSettings {
|
||||||
max_chunk_chars: number;
|
max_chunk_chars: number;
|
||||||
crossfade_ms: number;
|
crossfade_ms: number;
|
||||||
|
|||||||
@@ -1,27 +1,34 @@
|
|||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
|
import { useDictationReadiness } from '@/lib/hooks/useDictationReadiness';
|
||||||
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
import { useCaptureSettings } from '@/lib/hooks/useSettings';
|
||||||
import { usePlatform } from '@/platform/PlatformContext';
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Spawn (or quiet) the global hotkey monitor based on the saved
|
* Spawn (or quiet) the global hotkey monitor based on the saved
|
||||||
* `capture_settings.hotkey_enabled` flag, and keep its bindings in sync with
|
* `capture_settings.hotkey_enabled` flag AND the dictation readiness gates,
|
||||||
* the user's chord choices.
|
* and keep its bindings in sync with the user's chord choices.
|
||||||
*
|
*
|
||||||
* Boot sequence:
|
* Boot sequence:
|
||||||
* - hotkey_enabled = false → call `disable_hotkey` (no-op if monitor was
|
* - hotkey_enabled = false OR any readiness gate missing → call
|
||||||
* never spawned). Crucially, we do *not* call `enable_hotkey`, so the
|
* `disable_hotkey` (no-op if monitor was never spawned). Crucially, we do
|
||||||
* macOS Input Monitoring TCC prompt is never triggered for users who
|
* *not* call `enable_hotkey` in this state, so the macOS Input Monitoring
|
||||||
* haven't opted in.
|
* TCC prompt is never triggered for users who haven't opted in, AND the
|
||||||
* - hotkey_enabled = true → call `enable_hotkey` with the saved chords.
|
* chord physically can't fire when models aren't downloaded — preventing
|
||||||
* This is the call that creates the CGEventTap and triggers the TCC
|
* the "stuck pill" failure mode where dictation triggers but has nowhere
|
||||||
* prompt on first opt-in.
|
* to land.
|
||||||
|
* - hotkey_enabled = true AND all gates green → call `enable_hotkey` with
|
||||||
|
* the saved chords. This creates the CGEventTap and triggers the TCC
|
||||||
|
* prompt on first opt-in. Re-runs whenever a gate flips green (e.g. the
|
||||||
|
* user finishes downloading Whisper in another tab) so the chord
|
||||||
|
* auto-arms without making the user toggle off/on.
|
||||||
*
|
*
|
||||||
* Call once from the main app shell.
|
* Call once from the main app shell.
|
||||||
*/
|
*/
|
||||||
export function useChordSync() {
|
export function useChordSync() {
|
||||||
const platform = usePlatform();
|
const platform = usePlatform();
|
||||||
const { settings } = useCaptureSettings();
|
const { settings } = useCaptureSettings();
|
||||||
|
const { allReady } = useDictationReadiness();
|
||||||
const enabled = settings?.hotkey_enabled;
|
const enabled = settings?.hotkey_enabled;
|
||||||
const pushKeys = settings?.chord_push_to_talk_keys;
|
const pushKeys = settings?.chord_push_to_talk_keys;
|
||||||
const toggleKeys = settings?.chord_toggle_to_talk_keys;
|
const toggleKeys = settings?.chord_toggle_to_talk_keys;
|
||||||
@@ -29,16 +36,16 @@ export function useChordSync() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!platform.metadata.isTauri) return;
|
if (!platform.metadata.isTauri) return;
|
||||||
if (enabled === undefined || !pushKeys || !toggleKeys) return;
|
if (enabled === undefined || !pushKeys || !toggleKeys) return;
|
||||||
const command = enabled ? 'enable_hotkey' : 'disable_hotkey';
|
const shouldArm = enabled && allReady;
|
||||||
const args = enabled
|
const command = shouldArm ? 'enable_hotkey' : 'disable_hotkey';
|
||||||
? { pushToTalk: pushKeys, toggleToTalk: toggleKeys }
|
const args = shouldArm ? { pushToTalk: pushKeys, toggleToTalk: toggleKeys } : {};
|
||||||
: {};
|
|
||||||
invoke(command, args).catch((err) => {
|
invoke(command, args).catch((err) => {
|
||||||
console.warn(`[chord-sync] ${command} failed:`, err);
|
console.warn(`[chord-sync] ${command} failed:`, err);
|
||||||
});
|
});
|
||||||
}, [
|
}, [
|
||||||
platform.metadata.isTauri,
|
platform.metadata.isTauri,
|
||||||
enabled,
|
enabled,
|
||||||
|
allReady,
|
||||||
// Stringify so a referentially-new array with the same content
|
// Stringify so a referentially-new array with the same content
|
||||||
// doesn't fire a redundant invoke on every settings refetch.
|
// doesn't fire a redundant invoke on every settings refetch.
|
||||||
pushKeys?.join(','),
|
pushKeys?.join(','),
|
||||||
|
|||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import { useQuery } from '@tanstack/react-query';
|
||||||
|
import { useAccessibilityPermission } from '@/components/AccessibilityGate/AccessibilityGate';
|
||||||
|
import { useInputMonitoringPermission } from '@/components/InputMonitoringGate/InputMonitoringGate';
|
||||||
|
import { apiClient } from '@/lib/api/client';
|
||||||
|
import type { ModelReadiness } from '@/lib/api/types';
|
||||||
|
import { usePlatform } from '@/platform/PlatformContext';
|
||||||
|
|
||||||
|
const READINESS_POLL_INTERVAL_MS = 5_000;
|
||||||
|
|
||||||
|
export type ReadinessGate = 'stt' | 'llm' | 'input_monitoring' | 'accessibility';
|
||||||
|
|
||||||
|
export interface DictationReadiness {
|
||||||
|
isLoading: boolean;
|
||||||
|
allReady: boolean;
|
||||||
|
/** Subset of gates that are NOT yet satisfied — what the checklist renders. */
|
||||||
|
missing: ReadinessGate[];
|
||||||
|
stt: ModelReadiness | undefined;
|
||||||
|
llm: ModelReadiness | undefined;
|
||||||
|
inputMonitoring: boolean;
|
||||||
|
accessibility: boolean;
|
||||||
|
refetch: () => void;
|
||||||
|
openInputMonitoringSettings: () => Promise<void>;
|
||||||
|
openAccessibilitySettings: () => Promise<void>;
|
||||||
|
recheckInputMonitoring: () => Promise<boolean>;
|
||||||
|
recheckAccessibility: () => Promise<boolean>;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Single source of truth for "can the user trigger dictation right now?"
|
||||||
|
*
|
||||||
|
* Combines four gates into one struct so the chord-sync hook can refuse to
|
||||||
|
* arm the global hotkey unless every gate is green — the "stuck pill" we
|
||||||
|
* used to get on missing models is solved by never letting the chord fire
|
||||||
|
* in the first place.
|
||||||
|
*
|
||||||
|
* Gates:
|
||||||
|
* - stt / llm: backend ``/capture/readiness`` (polled, since downloads
|
||||||
|
* finish out-of-band — e.g. user kicks off a download in another tab and
|
||||||
|
* expects the toggle to auto-unlock when it lands)
|
||||||
|
* - input_monitoring / accessibility: macOS TCC checks via Tauri commands
|
||||||
|
* (rechecked on window focus by the underlying hooks)
|
||||||
|
*
|
||||||
|
* Hotkey-enabled is the user's intent toggle and is intentionally *not*
|
||||||
|
* a gate here — that's `useChordSync`'s concern.
|
||||||
|
*/
|
||||||
|
export function useDictationReadiness(): DictationReadiness {
|
||||||
|
const platform = usePlatform();
|
||||||
|
const isTauri = platform.metadata.isTauri;
|
||||||
|
|
||||||
|
const {
|
||||||
|
needsPermission: inputMonNeeds,
|
||||||
|
recheck: recheckInputMon,
|
||||||
|
openSettings: openInputMon,
|
||||||
|
} = useInputMonitoringPermission();
|
||||||
|
const {
|
||||||
|
needsPermission: a11yNeeds,
|
||||||
|
recheck: recheckA11y,
|
||||||
|
openSettings: openA11y,
|
||||||
|
} = useAccessibilityPermission();
|
||||||
|
|
||||||
|
const { data, isLoading, refetch } = useQuery({
|
||||||
|
queryKey: ['capture-readiness'],
|
||||||
|
queryFn: () => apiClient.getCaptureReadiness(),
|
||||||
|
refetchInterval: READINESS_POLL_INTERVAL_MS,
|
||||||
|
refetchOnWindowFocus: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
// On the web build there's no TCC layer — treat both as granted so the
|
||||||
|
// checklist doesn't block users who can't even open System Settings.
|
||||||
|
const inputMonitoring = isTauri ? !inputMonNeeds : true;
|
||||||
|
const accessibility = isTauri ? !a11yNeeds : true;
|
||||||
|
const sttReady = data?.stt.ready ?? false;
|
||||||
|
const llmReady = data?.llm.ready ?? false;
|
||||||
|
|
||||||
|
const missing: ReadinessGate[] = [];
|
||||||
|
if (!sttReady) missing.push('stt');
|
||||||
|
if (!llmReady) missing.push('llm');
|
||||||
|
if (!inputMonitoring) missing.push('input_monitoring');
|
||||||
|
if (!accessibility) missing.push('accessibility');
|
||||||
|
|
||||||
|
return {
|
||||||
|
isLoading,
|
||||||
|
allReady: missing.length === 0,
|
||||||
|
missing,
|
||||||
|
stt: data?.stt,
|
||||||
|
llm: data?.llm,
|
||||||
|
inputMonitoring,
|
||||||
|
accessibility,
|
||||||
|
refetch: () => {
|
||||||
|
refetch();
|
||||||
|
},
|
||||||
|
openInputMonitoringSettings: openInputMon,
|
||||||
|
openAccessibilitySettings: openA11y,
|
||||||
|
recheckInputMonitoring: recheckInputMon,
|
||||||
|
recheckAccessibility: recheckA11y,
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -480,6 +480,11 @@ def get_llm_model_configs() -> list[ModelConfig]:
|
|||||||
return _get_qwen_llm_configs()
|
return _get_qwen_llm_configs()
|
||||||
|
|
||||||
|
|
||||||
|
def get_stt_model_configs() -> list[ModelConfig]:
|
||||||
|
"""Return only STT (Whisper) model configs."""
|
||||||
|
return _get_whisper_configs()
|
||||||
|
|
||||||
|
|
||||||
# Lookup helpers — these replace the if/elif chains in main.py
|
# Lookup helpers — these replace the if/elif chains in main.py
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -427,6 +427,34 @@ class PersonalitySpeakRequest(BaseModel):
|
|||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class ModelReadiness(BaseModel):
|
||||||
|
"""Per-model entry in the dictation readiness checklist.
|
||||||
|
|
||||||
|
``model_name`` is the canonical id used by ``POST /models/download`` so the
|
||||||
|
frontend can wire a one-click "Download" button without a second lookup.
|
||||||
|
``size`` is the user's chosen variant (e.g. "turbo", "0.6B"); ``display_name``
|
||||||
|
is what the checklist row should show ("Whisper Turbo").
|
||||||
|
"""
|
||||||
|
|
||||||
|
ready: bool
|
||||||
|
model_name: str
|
||||||
|
display_name: str
|
||||||
|
size: str
|
||||||
|
size_mb: Optional[int] = None
|
||||||
|
|
||||||
|
|
||||||
|
class CaptureReadinessResponse(BaseModel):
|
||||||
|
"""Backend gates that must be green before the global hotkey will fire.
|
||||||
|
|
||||||
|
The frontend combines this with its own TCC permission checks (input
|
||||||
|
monitoring, accessibility) into the full dictation readiness checklist.
|
||||||
|
Hotkey-enabled is the user's intent toggle and lives outside this struct.
|
||||||
|
"""
|
||||||
|
|
||||||
|
stt: ModelReadiness
|
||||||
|
llm: ModelReadiness
|
||||||
|
|
||||||
|
|
||||||
class HealthResponse(BaseModel):
|
class HealthResponse(BaseModel):
|
||||||
"""Response model for health check."""
|
"""Response model for health check."""
|
||||||
|
|
||||||
|
|||||||
@@ -7,6 +7,8 @@ from fastapi.responses import FileResponse
|
|||||||
from sqlalchemy.orm import Session
|
from sqlalchemy.orm import Session
|
||||||
|
|
||||||
from .. import config, models
|
from .. import config, models
|
||||||
|
from ..backends import get_llm_model_configs, get_stt_model_configs
|
||||||
|
from ..backends.base import is_model_cached
|
||||||
from ..database import Capture as DBCapture, get_db
|
from ..database import Capture as DBCapture, get_db
|
||||||
from ..services import captures as captures_service
|
from ..services import captures as captures_service
|
||||||
from ..services import settings as settings_service
|
from ..services import settings as settings_service
|
||||||
@@ -152,6 +154,52 @@ async def refine_capture_endpoint(
|
|||||||
return capture
|
return capture
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/capture/readiness", response_model=models.CaptureReadinessResponse)
|
||||||
|
async def capture_readiness_endpoint(db: Session = Depends(get_db)):
|
||||||
|
"""Whether the STT and LLM models the user has selected are downloaded.
|
||||||
|
|
||||||
|
The frontend gates the global hotkey on this — pressing the chord with
|
||||||
|
a missing model would otherwise produce a stuck "transcribing" pill that
|
||||||
|
waits forever for a download to finish. Checks on-disk cache, not RAM
|
||||||
|
load, so the answer survives backend restarts.
|
||||||
|
"""
|
||||||
|
saved = settings_service.get_capture_settings(db)
|
||||||
|
|
||||||
|
stt_cfg = next(
|
||||||
|
(c for c in get_stt_model_configs() if c.model_size == saved.stt_model),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
llm_cfg = next(
|
||||||
|
(c for c in get_llm_model_configs() if c.model_size == saved.llm_model),
|
||||||
|
None,
|
||||||
|
)
|
||||||
|
|
||||||
|
if stt_cfg is None or llm_cfg is None:
|
||||||
|
# Should be impossible — both fields are pattern-validated against
|
||||||
|
# known sizes — but bail loudly rather than return half a response.
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=500,
|
||||||
|
detail=f"No model config for stt={saved.stt_model} or llm={saved.llm_model}",
|
||||||
|
)
|
||||||
|
|
||||||
|
return models.CaptureReadinessResponse(
|
||||||
|
stt=models.ModelReadiness(
|
||||||
|
ready=is_model_cached(stt_cfg.hf_repo_id),
|
||||||
|
model_name=stt_cfg.model_name,
|
||||||
|
display_name=stt_cfg.display_name,
|
||||||
|
size=stt_cfg.model_size,
|
||||||
|
size_mb=stt_cfg.size_mb or None,
|
||||||
|
),
|
||||||
|
llm=models.ModelReadiness(
|
||||||
|
ready=is_model_cached(llm_cfg.hf_repo_id),
|
||||||
|
model_name=llm_cfg.model_name,
|
||||||
|
display_name=llm_cfg.display_name,
|
||||||
|
size=llm_cfg.model_size,
|
||||||
|
size_mb=llm_cfg.size_mb or None,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
@router.post("/captures/{capture_id}/retranscribe", response_model=models.CaptureResponse)
|
@router.post("/captures/{capture_id}/retranscribe", response_model=models.CaptureResponse)
|
||||||
async def retranscribe_capture_endpoint(
|
async def retranscribe_capture_endpoint(
|
||||||
capture_id: str,
|
capture_id: str,
|
||||||
|
|||||||
Reference in New Issue
Block a user