Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
174 changes: 174 additions & 0 deletions apps/desktop/src/components/onboarding/configure-notice.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,15 @@
import { useQuery } from "@tanstack/react-query";
import { useCallback, useEffect, useState } from "react";

import {
commands as localSttCommands,
type SupportedSttModel,
} from "@hypr/plugin-local-stt";
import { cn } from "@hypr/utils";

import { localSttQueries } from "../../hooks/useLocalSttModel";
import { Route } from "../../routes/app/onboarding";
import * as settings from "../../store/tinybase/settings";
import { getBack, getNext, type StepProps } from "./config";
import { OnboardingContainer } from "./shared";

Expand All @@ -8,6 +19,17 @@ export function ConfigureNotice({ onNavigate }: StepProps) {
const search = Route.useSearch();
const backStep = getBack(search);

if (search.local) {
return (
<LocalConfigureNotice
onNavigate={onNavigate}
onBack={
backStep ? () => onNavigate({ ...search, step: backStep }) : undefined
}
/>
);
}

return (
<OnboardingContainer
title="AI models are needed for best experience"
Expand Down Expand Up @@ -39,6 +61,158 @@ export function ConfigureNotice({ onNavigate }: StepProps) {
);
}

function LocalConfigureNotice({
onNavigate,
onBack,
}: {
onNavigate: StepProps["onNavigate"];
onBack?: () => void;
}) {
const search = Route.useSearch();
const [selectedModel, setSelectedModel] = useState<SupportedSttModel | null>(
null,
);

const handleSelectProvider = settings.UI.useSetValueCallback(
"current_stt_provider",
(provider: string) => provider,
[],
settings.STORE_ID,
);

const handleSelectModel = settings.UI.useSetValueCallback(
"current_stt_model",
(model: string) => model,
[],
settings.STORE_ID,
);

const p2Downloaded = useQuery(localSttQueries.isDownloaded("am-parakeet-v2"));
const p3Downloaded = useQuery(localSttQueries.isDownloaded("am-parakeet-v3"));

useEffect(() => {
if (p2Downloaded.data || p3Downloaded.data) {
onNavigate({ ...search, step: getNext(search) });
}
}, [p2Downloaded.data, p3Downloaded.data, search, onNavigate]);

const handleUseModel = useCallback(() => {
if (!selectedModel) return;

handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
Comment on lines +99 to +112
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

⚠️ Potential issue | 🟠 Major

Download initiated but navigation happens immediately without waiting for completion.

handleUseModel calls downloadModel and then navigates to the next step immediately without awaiting the download result. This means the user is navigated away before knowing if the download started successfully. Consider either:

  1. Waiting for the download command to return before navigating
  2. Or keeping the user on this screen to show download progress (similar to the settings page behavior)

The current approach differs from HyprProviderLocalRow in configure.tsx which shows progress and doesn't navigate away.

🔎 Suggested approach to handle download result
  const handleUseModel = useCallback(() => {
    if (!selectedModel) return;

    handleSelectProvider("hyprnote");
    handleSelectModel(selectedModel);
-   void localSttCommands.downloadModel(selectedModel);
-   onNavigate({ ...search, step: getNext(search) });
+   void localSttCommands.downloadModel(selectedModel).then((result) => {
+     if (result.status === "ok") {
+       onNavigate({ ...search, step: getNext(search) });
+     }
+   });
  }, [
📝 Committable suggestion

‼️ IMPORTANT
Carefully review the code before committing. Ensure that it accurately replaces the highlighted code, contains no missing lines, and has no issues with indentation. Thoroughly test & benchmark the code to ensure it meets the requirements.

Suggested change
const handleUseModel = useCallback(() => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
const handleUseModel = useCallback(() => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel).then((result) => {
if (result.status === "ok") {
onNavigate({ ...search, step: getNext(search) });
}
});
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
🤖 Prompt for AI Agents
In apps/desktop/src/components/onboarding/configure-notice.tsx around lines 99
to 112, the handler kicks off localSttCommands.downloadModel(selectedModel) but
immediately navigates away; change the callback to an async handler that awaits
the download, sets and uses a loading/progress state, and only calls onNavigate
after a successful download (or shows an error and stays on the page on
failure). Implement try/catch around the await to surface errors (e.g., toast or
inline error state), disable UI controls while downloading, and ensure
dependencies (like localSttCommands) are included in the effect/handler closure.

Comment on lines +99 to +112
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Critical bug: The download is started but errors are not handled, and navigation happens immediately without waiting for download to start successfully.

If localSttCommands.downloadModel() returns an error status (e.g., network failure, insufficient disk space), the user will:

  1. Navigate to the next onboarding step
  2. Believe the model is downloading when it actually failed
  3. Complete onboarding with no working STT model

The settings page (configure.tsx, old lines 401-407) properly handles this by checking the result status and setting error state.

Fix:

const handleUseModel = useCallback(async () => {
  if (!selectedModel) return;

  handleSelectProvider("hyprnote");
  handleSelectModel(selectedModel);
  
  const result = await localSttCommands.downloadModel(selectedModel);
  if (result.status === "error") {
    // Handle error - show toast/alert to user
    return;
  }
  
  onNavigate({ ...search, step: getNext(search) });
}, [...]);
Suggested change
const handleUseModel = useCallback(() => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
void localSttCommands.downloadModel(selectedModel);
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);
const handleUseModel = useCallback(async () => {
if (!selectedModel) return;
handleSelectProvider("hyprnote");
handleSelectModel(selectedModel);
const result = await localSttCommands.downloadModel(selectedModel);
if (result.status === "error") {
// Handle error - show toast/alert to user
return;
}
onNavigate({ ...search, step: getNext(search) });
}, [
selectedModel,
search,
onNavigate,
handleSelectProvider,
handleSelectModel,
]);

Spotted by Graphite Agent

Fix in Graphite


Is this helpful? React 👍 or 👎 to let us know.


if (p2Downloaded.isLoading || p3Downloaded.isLoading) {
return (
<OnboardingContainer
title="Checking for existing models..."
onBack={onBack}
>
<div className="flex justify-center py-8">
<div className="animate-spin rounded-full h-8 w-8 border-b-2 border-stone-500"></div>
</div>
</OnboardingContainer>
);
}

return (
<OnboardingContainer
title="Help Hyprnote listen to your conversations"
description="Select a speech-to-text model to download"
onBack={onBack}
>
<div className="flex flex-col gap-3">
<LocalModelRow
model="am-parakeet-v2"
displayName="Parakeet v2"
description="Best for English"
isSelected={selectedModel === "am-parakeet-v2"}
onSelect={() => setSelectedModel("am-parakeet-v2")}
/>
<LocalModelRow
model="am-parakeet-v3"
displayName="Parakeet v3"
description="Better for European languages"
isSelected={selectedModel === "am-parakeet-v3"}
onSelect={() => setSelectedModel("am-parakeet-v3")}
/>
</div>

<div className="flex flex-col gap-3 mt-4">
<button
onClick={handleUseModel}
disabled={!selectedModel}
className={cn([
"w-full py-3 rounded-full text-white text-sm font-medium duration-150",
selectedModel
? "bg-gradient-to-t from-stone-600 to-stone-500 hover:scale-[1.01] active:scale-[0.99]"
: "bg-gray-300 cursor-not-allowed opacity-50",
])}
>
Use this model
</button>
</div>
</OnboardingContainer>
);
}

function LocalModelRow({
model,
displayName,
description,
isSelected,
onSelect,
}: {
model: SupportedSttModel;
displayName: string;
description: string;
isSelected: boolean;
onSelect: () => void;
}) {
const isDownloaded = useQuery(localSttQueries.isDownloaded(model));

return (
<div
role="button"
tabIndex={0}
onClick={onSelect}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onSelect();
}
}}
className={cn([
"relative border rounded-xl py-3 px-4 flex flex-col gap-1 text-left transition-all cursor-pointer",
isSelected
? "border-stone-500 bg-stone-50"
: "border-neutral-200 hover:border-neutral-300",
])}
>
<div className="flex items-center justify-between w-full">
<div className="flex flex-col gap-1">
<p className="text-sm font-medium">{displayName}</p>
<p className="text-xs text-neutral-500 flex-1">{description}</p>
</div>
{isDownloaded.data && (
<span className="text-xs text-green-600 font-medium">
Already downloaded
</span>
)}
</div>
</div>
);
}

function Requirement({
title,
description,
Expand Down
84 changes: 3 additions & 81 deletions apps/desktop/src/components/settings/ai/stt/configure.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,11 +2,10 @@ import { Icon } from "@iconify-icon/react";
import { useQuery } from "@tanstack/react-query";
import { openPath } from "@tauri-apps/plugin-opener";
import { arch, platform } from "@tauri-apps/plugin-os";
import { useCallback, useEffect, useState } from "react";
import { useCallback } from "react";

import {
commands as localSttCommands,
events as localSttEvents,
type SupportedSttModel,
} from "@hypr/plugin-local-stt";
import {
Expand All @@ -20,9 +19,10 @@ import { cn } from "@hypr/utils";

import { useBillingAccess } from "../../../../billing";
import { useListener } from "../../../../contexts/listener";
import { useLocalModelDownload } from "../../../../hooks/useLocalSttModel";
import * as settings from "../../../../store/tinybase/settings";
import { NonHyprProviderCard, StyledStreamdown } from "../shared";
import { ProviderId, PROVIDERS, sttModelQueries } from "./shared";
import { ProviderId, PROVIDERS } from "./shared";

export function ConfigureProviders() {
return (
Expand Down Expand Up @@ -344,84 +344,6 @@ function HyprProviderLocalRow({
);
}

function useLocalModelDownload(
model: SupportedSttModel,
onDownloadComplete?: (model: SupportedSttModel) => void,
) {
const [progress, setProgress] = useState<number>(0);
const [isStarting, setIsStarting] = useState(false);
const [hasError, setHasError] = useState(false);

const isDownloaded = useQuery(sttModelQueries.isDownloaded(model));
const isDownloading = useQuery(sttModelQueries.isDownloading(model));

const showProgress =
!isDownloaded.data && (isStarting || (isDownloading.data ?? false));

useEffect(() => {
if (isDownloading.data) {
setIsStarting(false);
}
}, [isDownloading.data]);

useEffect(() => {
const unlisten = localSttEvents.downloadProgressPayload.listen((event) => {
if (event.payload.model === model) {
if (event.payload.progress < 0) {
setHasError(true);
setIsStarting(false);
setProgress(0);
} else {
setHasError(false);
const next = Math.max(0, Math.min(100, event.payload.progress));
setProgress(next);
}
}
});

return () => {
void unlisten.then((fn) => fn());
};
}, [model]);

useEffect(() => {
if (isDownloaded.data && progress > 0) {
setProgress(0);
onDownloadComplete?.(model);
}
}, [isDownloaded.data, model, onDownloadComplete, progress]);

const handleDownload = () => {
if (isDownloaded.data || isDownloading.data || isStarting) {
return;
}
setHasError(false);
setIsStarting(true);
setProgress(0);
void localSttCommands.downloadModel(model).then((result) => {
if (result.status === "error") {
setHasError(true);
setIsStarting(false);
}
});
};

const handleCancel = () => {
void localSttCommands.cancelDownload(model);
setIsStarting(false);
setProgress(0);
};

return {
progress,
hasError,
isDownloaded: isDownloaded.data ?? false,
showProgress,
handleDownload,
handleCancel,
};
}

function ProviderContext({ providerId }: { providerId: ProviderId }) {
const content =
providerId === "hyprnote"
Expand Down
34 changes: 3 additions & 31 deletions apps/desktop/src/components/settings/ai/stt/shared.tsx
Original file line number Diff line number Diff line change
@@ -1,22 +1,23 @@
import { Icon } from "@iconify-icon/react";
import { AssemblyAI, Fireworks, OpenAI } from "@lobehub/icons";
import { queryOptions } from "@tanstack/react-query";
import type { ReactNode } from "react";

import { commands as localSttCommands } from "@hypr/plugin-local-stt";
import type {
AmModel,
SupportedSttModel,
WhisperModel,
} from "@hypr/plugin-local-stt";

import { env } from "../../../../env";
import { localSttQueries } from "../../../../hooks/useLocalSttModel";
import {
type ProviderRequirement,
requiresEntitlement,
} from "../shared/eligibility";
import { sortProviders } from "../shared/sort-providers";

export { localSttQueries as sttModelQueries };

type Provider = {
disabled: boolean;
id: string;
Expand Down Expand Up @@ -200,32 +201,3 @@ export const sttProviderRequiresPro = (providerId: ProviderId) => {
const provider = PROVIDERS.find((p) => p.id === providerId);
return provider ? requiresEntitlement(provider.requirements, "pro") : false;
};

export const sttModelQueries = {
isDownloaded: (model: SupportedSttModel) =>
queryOptions({
refetchInterval: 1000,
queryKey: ["stt", "model", model, "downloaded"],
queryFn: () => localSttCommands.isModelDownloaded(model),
select: (result) => {
if (result.status === "error") {
throw new Error(result.error);
}

return result.data;
},
}),
isDownloading: (model: SupportedSttModel) =>
queryOptions({
refetchInterval: 1000,
queryKey: ["stt", "model", model, "downloading"],
queryFn: () => localSttCommands.isModelDownloading(model),
select: (result) => {
if (result.status === "error") {
throw new Error(result.error);
}

return result.data;
},
}),
};
Loading
Loading