diff --git a/.github/ISSUE_TEMPLATE/bug_report.md b/.github/ISSUE_TEMPLATE/bug_report.md index 6edf200..f99bf1f 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.md +++ b/.github/ISSUE_TEMPLATE/bug_report.md @@ -1,10 +1,9 @@ --- name: Bug report about: Create a report to help us improve -title: '' +title: "" labels: bug -assignees: '' - +assignees: "" --- **Describe the bug** @@ -14,6 +13,7 @@ A clear and concise description of what the bug is. What version of Amical are you using? (e.g., v0.1.1) **System Information** + - **Operating System:** [e.g., macOS 14.0, Windows 11, Ubuntu 22.04] - **RAM:** [e.g., 16GB] - **GPU:** [e.g., NVIDIA RTX 3060, Apple M1, Intel Iris Xe, N/A] @@ -21,6 +21,7 @@ What version of Amical are you using? (e.g., v0.1.1) **To Reproduce** Steps to reproduce the behavior: + 1. Go to '...' 2. Click on '....' 3. Scroll down to '....' diff --git a/apps/desktop/.env.example b/apps/desktop/.env.example new file mode 100644 index 0000000..dd4bd1b --- /dev/null +++ b/apps/desktop/.env.example @@ -0,0 +1,34 @@ +LOG_LEVEL=info +# FORCE_ONBOARDING=true + +# Apple Notarization Configuration (for macOS builds) +# APPLE_ID=your.email@example.com +# APPLE_APP_PASSWORD=xxxx-xxxx-xxxx-xxxx +# APPLE_TEAM_ID=XXXXXXXXXX +# CODESIGNING_IDENTITY="Developer ID Application: Your Company (XXXXXXXXXX)" + +# Skip code signing for development (optional) +# SKIP_CODESIGNING=true + +# Skip notarization only (still code sign) for faster builds +# SKIP_NOTARIZATION=true + +# Telemetry +TELEMETRY_ENABLED=true +POSTHOG_HOST=https://app.posthog.com +POSTHOG_API_KEY=your-posthog-api-key + +# OAuth Configuration +# Required for Amical Cloud transcription +# Redirect URL to configure in your OIDC provider: amical://oauth/callback +AUTH_CLIENT_ID=your-oauth-client-id + +# Optional: Override OAuth endpoints (defaults are automatic based on NODE_ENV) +# Development defaults (NODE_ENV=development): +# Authorization: http://localhost:5001/api/auth/oauth2/authorize +# Token: http://localhost:5001/api/auth/oauth2/token +# Production defaults: +# Authorization: https://login.amical.ai/authorize +# Token: https://api.amical.ai/api/auth/oauth2/token +# AUTHORIZATION_ENDPOINT=https://your-auth-server.com/authorize +# AUTH_TOKEN_ENDPOINT=https://your-auth-server.com/token \ No newline at end of file diff --git a/apps/desktop/forge.config.ts b/apps/desktop/forge.config.ts index 8f5a1ad..1aae0a9 100644 --- a/apps/desktop/forge.config.ts +++ b/apps/desktop/forge.config.ts @@ -355,7 +355,19 @@ const config: ForgeConfig = { extendInfo: { NSMicrophoneUsageDescription: "This app needs access to your microphone to record audio for transcription.", + CFBundleURLTypes: [ + { + CFBundleURLSchemes: ["amical"], + CFBundleURLName: "com.amical.desktop", + }, + ], }, + protocols: [ + { + name: "Amical", + schemes: ["amical"], + }, + ], // Code signing configuration for macOS ...(process.env.SKIP_CODESIGNING === "true" ? {} diff --git a/apps/desktop/src/components/auth-button.tsx b/apps/desktop/src/components/auth-button.tsx new file mode 100644 index 0000000..da4c3c6 --- /dev/null +++ b/apps/desktop/src/components/auth-button.tsx @@ -0,0 +1,196 @@ +import { useState, useRef, useEffect } from "react"; +import { LogIn, LogOut, Loader2 } from "lucide-react"; +import { api } from "@/trpc/react"; +import { toast } from "sonner"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuLabel, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { SidebarMenuItem, SidebarMenuButton } from "@/components/ui/sidebar"; + +// Helper function to generate initials from email or name +function getInitials(email?: string | null, name?: string | null): string { + if (name) { + const parts = name.trim().split(/\s+/); + if (parts.length >= 2) { + return `${parts[0][0]}${parts[1][0]}`.toUpperCase(); + } + return name.substring(0, 2).toUpperCase(); + } + + if (email) { + const localPart = email.split("@")[0]; + return localPart.substring(0, 2).toUpperCase(); + } + + return "??"; +} + +export function AuthButton() { + const [isLoading, setIsLoading] = useState(false); + const loadingTimeoutRef = useRef(null); + + // Get current auth status + const authStatusQuery = api.auth.getAuthStatus.useQuery(); + + // Clear loading timeout helper + const clearLoadingTimeout = () => { + if (loadingTimeoutRef.current) { + clearTimeout(loadingTimeoutRef.current); + loadingTimeoutRef.current = null; + } + }; + + // Cleanup timeout on unmount + useEffect(() => { + return () => { + clearLoadingTimeout(); + }; + }, []); + + // Auth mutations + const loginMutation = api.auth.login.useMutation({ + onMutate: () => { + setIsLoading(true); + // Set a 5-second timeout to reset loading state if auth doesn't complete + clearLoadingTimeout(); // Clear any existing timeout + loadingTimeoutRef.current = setTimeout(() => { + setIsLoading(false); + }, 5000); + }, + onSuccess: () => { + toast.info("Opening browser for sign in..."); + }, + onError: (error) => { + clearLoadingTimeout(); + toast.error("Failed to initiate login", { + description: error.message, + }); + setIsLoading(false); + }, + }); + + const logoutMutation = api.auth.logout.useMutation({ + onMutate: () => { + setIsLoading(true); + }, + onSuccess: () => { + toast.success("Signed out successfully"); + setIsLoading(false); + // Invalidate auth queries + authStatusQuery.refetch(); + }, + onError: (error) => { + toast.error("Failed to sign out", { + description: error.message, + }); + setIsLoading(false); + }, + }); + + // Subscribe to auth state changes + api.auth.onAuthStateChange.useSubscription(undefined, { + onData: (data) => { + // Auth state changed, refetch status + clearLoadingTimeout(); + authStatusQuery.refetch(); + setIsLoading(false); + if (data.isAuthenticated) { + toast.success("Signed in successfully"); + } + }, + onError: (error) => { + console.error("Auth state subscription error:", error); + }, + }); + + const handleLogin = () => { + loginMutation.mutate(); + }; + + const handleLogout = () => { + logoutMutation.mutate(); + }; + + const isAuthenticated = authStatusQuery.data?.isAuthenticated || false; + const userEmail = authStatusQuery.data?.userEmail; + const userName = authStatusQuery.data?.userName; + + if (authStatusQuery.isLoading) { + return ( + + + + Loading... + + + ); + } + + if (isAuthenticated) { + const initials = getInitials(userEmail, userName); + const displayName = userName || userEmail || "Account"; + + return ( + + + + + + + {initials} + + + {displayName} + + + + +
+ {userName && ( +

{userName}

+ )} + {userEmail && ( +

+ {userEmail} +

+ )} +
+
+ + + {isLoading ? ( + + ) : ( + + )} + Sign Out + +
+
+
+ ); + } + + return ( + + + {isLoading ? ( + + ) : ( + + )} + Sign In + + + ); +} diff --git a/apps/desktop/src/components/nav-secondary.tsx b/apps/desktop/src/components/nav-secondary.tsx index 91a2602..37942c8 100644 --- a/apps/desktop/src/components/nav-secondary.tsx +++ b/apps/desktop/src/components/nav-secondary.tsx @@ -8,6 +8,7 @@ import { SidebarMenuButton, SidebarMenuItem, } from "@/components/ui/sidebar"; +import { AuthButton } from "@/components/auth-button"; export function NavSecondary({ items, @@ -38,6 +39,7 @@ export function NavSecondary({ ))} + diff --git a/apps/desktop/src/components/shortcut-input.tsx b/apps/desktop/src/components/shortcut-input.tsx index 5f33e87..64f34c3 100644 --- a/apps/desktop/src/components/shortcut-input.tsx +++ b/apps/desktop/src/components/shortcut-input.tsx @@ -27,7 +27,10 @@ function validateShortcut(keys: string[]): ValidationResult { } if (keys.length > MAX_KEY_COMBINATION_LENGTH) { - return { valid: false, error: `Maximum ${MAX_KEY_COMBINATION_LENGTH} keys allowed` }; + return { + valid: false, + error: `Maximum ${MAX_KEY_COMBINATION_LENGTH} keys allowed`, + }; } const modifierKeys = keys.filter((key) => MODIFIER_KEYS.includes(key)); diff --git a/apps/desktop/src/constants/models.ts b/apps/desktop/src/constants/models.ts index 748f227..230867e 100644 --- a/apps/desktop/src/constants/models.ts +++ b/apps/desktop/src/constants/models.ts @@ -117,6 +117,37 @@ export interface ModelManagerState { // ]; export const AVAILABLE_MODELS: AvailableWhisperModel[] = [ + { + id: "amical-cloud", + name: "Amical Cloud", + type: "whisper", + description: "Fast cloud-based transcription with high accuracy.", + checksum: "", // No checksum for cloud model + filename: "", // No file for cloud model + downloadUrl: "", // No download for cloud model + size: 0, // No size for cloud model + sizeFormatted: "Cloud", + modelSize: "Cloud", + features: [ + { + icon: "cloud", + tooltip: "Cloud-based processing", + }, + { + icon: "bolt", + tooltip: "Fast transcription", + }, + { + icon: "languages", + tooltip: "Multilingual support", + }, + ], + speed: 4.5, + accuracy: 4.5, + setup: "cloud", + provider: "Amical Cloud", + providerIcon: "/assets/logo.svg", + }, { id: "whisper-tiny", name: "Whisper Tiny", diff --git a/apps/desktop/src/db/schema.ts b/apps/desktop/src/db/schema.ts index b0c09af..84cdfd0 100644 --- a/apps/desktop/src/db/schema.ts +++ b/apps/desktop/src/db/schema.ts @@ -162,6 +162,17 @@ export interface AppSettingsData { telemetry?: { enabled?: boolean; }; + auth?: { + isAuthenticated: boolean; + idToken: string | null; + refreshToken: string | null; + expiresAt: number | null; + userInfo?: { + sub: string; + email?: string; + name?: string; + }; + }; } // Notes table diff --git a/apps/desktop/src/main/core/app-manager.ts b/apps/desktop/src/main/core/app-manager.ts index 0c9a3fd..89c7f84 100644 --- a/apps/desktop/src/main/core/app-manager.ts +++ b/apps/desktop/src/main/core/app-manager.ts @@ -20,6 +20,35 @@ export class AppManager { this.trayManager = TrayManager.getInstance(); } + handleDeepLink(url: string): void { + logger.main.info("Handling deep link:", url); + + // Parse the URL + try { + const parsedUrl = new URL(url); + + // Handle auth callback + // For custom scheme URLs like amical://oauth/callback + // parsedUrl.host = "oauth" and parsedUrl.pathname = "/callback" + if (parsedUrl.host === "oauth" && parsedUrl.pathname === "/callback") { + const code = parsedUrl.searchParams.get("code"); + const state = parsedUrl.searchParams.get("state"); + + if (code) { + // Get AuthService and complete the OAuth flow + const authService = this.serviceManager.getService("authService"); + if (authService) { + authService.handleAuthCallback(code, state); + } + } + } + + // Add other deep link handlers here in the future + } catch (error) { + logger.main.error("Error handling deep link:", error); + } + } + async initialize(): Promise { await this.initializeDatabase(); diff --git a/apps/desktop/src/main/main.ts b/apps/desktop/src/main/main.ts index c96b367..3e46ed3 100644 --- a/apps/desktop/src/main/main.ts +++ b/apps/desktop/src/main/main.ts @@ -17,6 +17,17 @@ if (isWindows()) { app.setAppUserModelId("com.amical.desktop"); } +// Register the amical:// protocol +if (process.defaultApp) { + if (process.argv.length >= 2) { + app.setAsDefaultProtocolClient("amical", process.execPath, [ + process.argv[1], + ]); + } +} else { + app.setAsDefaultProtocolClient("amical"); +} + // Enforce single instance const gotTheLock = app.requestSingleInstanceLock(); @@ -42,13 +53,40 @@ if (app.isPackaged && isWindows()) { const appManager = new AppManager(); -// Handle when another instance tries to start -app.on("second-instance", () => { - // Someone tried to run a second instance, we should focus our window instead. - appManager.handleSecondInstance(); +// Store the deep link URL for processing after app is ready +let deeplinkingUrl: string | null = null; + +// Handle protocol on macOS +app.on("open-url", (event, url) => { + event.preventDefault(); + if (app.isReady()) { + appManager.handleDeepLink(url); + } else { + deeplinkingUrl = url; + } }); -app.whenReady().then(() => appManager.initialize()); +// Handle when another instance tries to start (Windows/Linux deep link handling) +app.on("second-instance", (_event, commandLine) => { + // Someone tried to run a second instance, we should focus our window instead. + appManager.handleSecondInstance(); + + // Check if this is a protocol launch on Windows/Linux + const url = commandLine.find((arg) => arg.startsWith("amical://")); + if (url) { + appManager.handleDeepLink(url); + } +}); + +app.whenReady().then(() => { + appManager.initialize(); + + // Process any deep link that was received before app was ready + if (deeplinkingUrl) { + appManager.handleDeepLink(deeplinkingUrl); + deeplinkingUrl = null; + } +}); app.on("will-quit", () => appManager.cleanup()); app.on("window-all-closed", () => { if (process.platform !== "darwin") app.quit(); diff --git a/apps/desktop/src/main/managers/recording-manager.ts b/apps/desktop/src/main/managers/recording-manager.ts index b97fab3..9ceb3de 100644 --- a/apps/desktop/src/main/managers/recording-manager.ts +++ b/apps/desktop/src/main/managers/recording-manager.ts @@ -408,7 +408,7 @@ export class RecordingManager extends EventEmitter { // Clean up session and audio recording on error this.currentSessionId = null; this.currentAudioRecording = null; - this.setState("error"); + this.setState("idle"); } } } diff --git a/apps/desktop/src/main/managers/service-manager.ts b/apps/desktop/src/main/managers/service-manager.ts index 05b84c8..6c3f774 100644 --- a/apps/desktop/src/main/managers/service-manager.ts +++ b/apps/desktop/src/main/managers/service-manager.ts @@ -13,6 +13,7 @@ import { router } from "../../trpc/router"; import { createContext } from "../../trpc/context"; import { isMacOS, isWindows } from "../../utils/platform"; import { TelemetryService } from "../../services/telemetry-service"; +import { AuthService } from "../../services/auth-service"; /** * Service map for type-safe service access @@ -22,6 +23,7 @@ export interface ServiceMap { modelManagerService: ModelManagerService; transcriptionService: TranscriptionService; settingsService: SettingsService; + authService: AuthService; vadService: VADService; nativeBridge: NativeBridge; autoUpdaterService: AutoUpdaterService; @@ -41,6 +43,7 @@ export class ServiceManager { private modelManagerService: ModelManagerService | null = null; private transcriptionService: TranscriptionService | null = null; private settingsService: SettingsService | null = null; + private authService: AuthService | null = null; private vadService: VADService | null = null; private nativeBridge: NativeBridge | null = null; @@ -60,6 +63,7 @@ export class ServiceManager { try { this.initializeSettingsService(); + this.initializeAuthService(); await this.initializeTelemetryService(); await this.initializeModelServices(); this.initializePlatformServices(); @@ -90,6 +94,11 @@ export class ServiceManager { logger.main.info("Settings service initialized"); } + private initializeAuthService(): void { + this.authService = AuthService.getInstance(); + logger.main.info("Auth service initialized"); + } + private async initializeModelServices(): Promise { // Initialize Model Manager Service if (!this.settingsService) { @@ -234,6 +243,7 @@ export class ServiceManager { modelManagerService: this.modelManagerService ?? undefined, transcriptionService: this.transcriptionService ?? undefined, settingsService: this.settingsService ?? undefined, + authService: this.authService ?? undefined, vadService: this.vadService ?? undefined, nativeBridge: this.nativeBridge ?? undefined, autoUpdaterService: this.autoUpdaterService ?? undefined, diff --git a/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts b/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts new file mode 100644 index 0000000..5f949e8 --- /dev/null +++ b/apps/desktop/src/pipeline/providers/transcription/amical-cloud-provider.ts @@ -0,0 +1,243 @@ +import { + TranscriptionProvider, + TranscribeParams, +} from "../../core/pipeline-types"; +import { logger } from "../../../main/logger"; +import { AuthService } from "../../../services/auth-service"; +import { getUserAgent } from "../../../utils/http-client"; + +interface CloudTranscriptionResponse { + success: boolean; + transcription?: string; + originalTranscription?: string; + language?: string; + duration?: number; + error?: string; +} + +export class AmicalCloudProvider implements TranscriptionProvider { + readonly name = "amical-cloud"; + + private authService: AuthService; + private apiEndpoint: string; + + // Frame aggregation state (similar to WhisperProvider) + private frameBuffer: Float32Array[] = []; + private frameBufferSpeechProbabilities: number[] = []; + private currentSilenceFrameCount = 0; + private lastSpeechTimestamp = 0; + + // Configuration + private readonly FRAME_SIZE = 512; // 32ms at 16kHz + private readonly MIN_SPEECH_DURATION_MS = 500; // Minimum speech duration to transcribe + private readonly MAX_SILENCE_DURATION_MS = 3000; // Max silence before cutting + private readonly SAMPLE_RATE = 16000; + private readonly SPEECH_PROBABILITY_THRESHOLD = 0.2; + + constructor() { + this.authService = AuthService.getInstance(); + + // Configure endpoint based on environment + this.apiEndpoint = "https://dictation.amical.ai"; + + logger.transcription.info("AmicalCloudProvider initialized", { + endpoint: this.apiEndpoint, + }); + } + + async transcribe( + params: TranscribeParams & { flush?: boolean }, + ): Promise { + try { + const { audioData, speechProbability = 1, flush = false } = params; + + // Check authentication + if (!(await this.authService.isAuthenticated())) { + throw new Error("Authentication required for cloud transcription"); + } + + // Add frame to buffer with speech probability + this.frameBuffer.push(audioData); + this.frameBufferSpeechProbabilities.push(speechProbability); + + // Consider it speech if probability is above threshold + const isSpeech = speechProbability > this.SPEECH_PROBABILITY_THRESHOLD; + + // Track speech and silence + const now = Date.now(); + if (isSpeech) { + this.currentSilenceFrameCount = 0; + this.lastSpeechTimestamp = now; + } else { + this.currentSilenceFrameCount++; + } + + // Calculate durations + const silenceDuration = + ((this.currentSilenceFrameCount * this.FRAME_SIZE) / this.SAMPLE_RATE) * + 1000; + const speechDuration = + ((this.frameBuffer.length * this.FRAME_SIZE) / this.SAMPLE_RATE) * 1000; + + // Determine if we should process + const shouldProcess = + flush || + (speechDuration >= this.MIN_SPEECH_DURATION_MS && + silenceDuration >= this.MAX_SILENCE_DURATION_MS); + + if (!shouldProcess) { + return ""; + } + + // Process accumulated audio + const result = await this.processAudio(); + + // Clear buffer after processing + this.frameBuffer = []; + this.frameBufferSpeechProbabilities = []; + this.currentSilenceFrameCount = 0; + + return result; + } catch (error) { + logger.transcription.error("Cloud transcription error:", error); + throw error; + } + } + + async flush(): Promise { + if (this.frameBuffer.length === 0) { + return ""; + } + + try { + const result = await this.processAudio(); + + // Clear buffer + this.frameBuffer = []; + this.frameBufferSpeechProbabilities = []; + this.currentSilenceFrameCount = 0; + + return result; + } catch (error) { + logger.transcription.error("Cloud flush error:", error); + throw error; + } + } + + private async processAudio(): Promise { + if (this.frameBuffer.length === 0) { + return ""; + } + + // Combine all frames into a single Float32Array + const totalLength = this.frameBuffer.reduce( + (acc, frame) => acc + frame.length, + 0, + ); + const combinedAudio = new Float32Array(totalLength); + let offset = 0; + for (const frame of this.frameBuffer) { + combinedAudio.set(frame, offset); + offset += frame.length; + } + + // Try transcription with automatic retry on 401 + return this.makeTranscriptionRequest(combinedAudio); + } + + private async makeTranscriptionRequest( + audioData: Float32Array, + isRetry = false, + ): Promise { + // Get auth token + const idToken = await this.authService.getIdToken(); + if (!idToken) { + throw new Error("No authentication token available"); + } + + // Calculate duration in seconds + const duration = audioData.length / this.SAMPLE_RATE; + + logger.transcription.info("Sending audio to cloud API", { + audioLength: audioData.length, + sampleRate: this.SAMPLE_RATE, + duration, + isRetry, + }); + + try { + const response = await fetch(`${this.apiEndpoint}/transcribe`, { + method: "POST", + headers: { + "Content-Type": "application/json", + Authorization: `Bearer ${idToken}`, + "User-Agent": getUserAgent(), + }, + body: JSON.stringify({ + audioData: Array.from(audioData), + }), + }); + + // Handle 401 with token refresh and retry + if (response.status === 401) { + if (isRetry) { + // Already retried once, give up + throw new Error("Authentication failed - please log in again"); + } + + logger.transcription.warn( + "Got 401 response, attempting token refresh and retry", + ); + + try { + // Force token refresh + await this.authService.refreshTokenIfNeeded(); + + // Retry the request once + return await this.makeTranscriptionRequest(audioData, true); + } catch (refreshError) { + logger.transcription.error("Token refresh failed:", refreshError); + throw new Error("Authentication failed - please log in again"); + } + } + + if (response.status === 403) { + throw new Error("Subscription required for cloud transcription"); + } + + if (response.status === 429) { + const errorData = await response.json(); + throw new Error( + `Word limit exceeded: ${errorData.currentWords}/${errorData.limit}`, + ); + } + + if (!response.ok) { + const errorText = await response.text(); + logger.transcription.error("Cloud API error:", { + status: response.status, + statusText: response.statusText, + error: errorText, + }); + throw new Error(`Cloud API error: ${response.statusText}`); + } + + const result: CloudTranscriptionResponse = await response.json(); + + if (!result.success) { + throw new Error(result.error || "Cloud transcription failed"); + } + + logger.transcription.info("Cloud transcription successful", { + textLength: result.transcription?.length || 0, + language: result.language, + duration: result.duration, + }); + + return result.transcription || ""; + } catch (error) { + logger.transcription.error("Cloud transcription request failed:", error); + throw error; + } + } +} diff --git a/apps/desktop/src/renderer/main/components/settings-sidebar.tsx b/apps/desktop/src/renderer/main/components/settings-sidebar.tsx index bbe91a5..9aa20f8 100644 --- a/apps/desktop/src/renderer/main/components/settings-sidebar.tsx +++ b/apps/desktop/src/renderer/main/components/settings-sidebar.tsx @@ -6,7 +6,6 @@ import { NavSecondary } from "@/components/nav-secondary"; import { Sidebar, SidebarContent, - SidebarFooter, SidebarHeader, SidebarMenu, SidebarMenuButton, @@ -78,7 +77,6 @@ export function SettingsSidebar({ - ); } diff --git a/apps/desktop/src/renderer/main/pages/settings/ai-models/components/default-model-combobox.tsx b/apps/desktop/src/renderer/main/pages/settings/ai-models/components/default-model-combobox.tsx index afc646c..3434333 100644 --- a/apps/desktop/src/renderer/main/pages/settings/ai-models/components/default-model-combobox.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/ai-models/components/default-model-combobox.tsx @@ -25,7 +25,7 @@ export default function DefaultModelCombobox({ // Unified queries const modelsQuery = api.models.getModels.useQuery({ type: modelType, - downloadedOnly: modelType === "speech", + selectable: true, // Only show models that can be selected (authenticated cloud or downloaded local) }); const defaultModelQuery = api.models.getDefaultModel.useQuery({ diff --git a/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx b/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx index 1ae8c8c..bda6210 100644 --- a/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx +++ b/apps/desktop/src/renderer/main/pages/settings/ai-models/tabs/SpeechTab.tsx @@ -12,9 +12,18 @@ import { TableHeader, TableRow, } from "@/components/ui/table"; -import { Download, Zap, Circle, Square, Loader2, Trash2 } from "lucide-react"; +import { + Download, + Zap, + Circle, + Square, + Loader2, + Trash2, + LogIn, + Cloud, +} from "lucide-react"; import { DynamicIcon } from "lucide-react/dynamic"; - +import { Button } from "@/components/ui/button"; import { TooltipContent, Tooltip, @@ -33,6 +42,14 @@ import { AlertDialogAction, AlertDialogCancel, } from "@/components/ui/alert-dialog"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; import { DownloadProgress } from "@/constants/models"; import { api } from "@/trpc/react"; @@ -100,6 +117,10 @@ export default function SpeechTab() { >({}); const [showDeleteDialog, setShowDeleteDialog] = useState(false); const [modelToDelete, setModelToDelete] = useState(null); + const [showLoginDialog, setShowLoginDialog] = useState(false); + const [pendingCloudModel, setPendingCloudModel] = useState( + null, + ); // tRPC queries const availableModelsQuery = api.models.getAvailableModels.useQuery(); @@ -109,6 +130,9 @@ export default function SpeechTab() { api.models.isTranscriptionAvailable.useQuery(); const selectedModelQuery = api.models.getSelectedModel.useQuery(); + // Auth queries + const isAuthenticatedQuery = api.auth.isAuthenticated.useQuery(); + const utils = api.useUtils(); // tRPC mutations @@ -157,6 +181,17 @@ export default function SpeechTab() { }, }); + // Auth mutations + const loginMutation = api.auth.login.useMutation({ + onSuccess: () => { + toast.info("Please complete login in your browser"); + }, + onError: (error) => { + console.error("Failed to initiate login:", error); + toast.error("Failed to start login process"); + }, + }); + // Initialize active downloads progress on load useEffect(() => { if (activeDownloadsQuery.data) { @@ -233,6 +268,20 @@ export default function SpeechTab() { }, }); + // Auth state subscription - handle login completion + api.auth.onAuthStateChange.useSubscription(undefined, { + onData: (authState) => { + if (authState.isAuthenticated && pendingCloudModel) { + toast.success("Login successful!"); + setSelectedModelMutation.mutate({ modelId: pendingCloudModel }); + setPendingCloudModel(null); + } + }, + onError: (error) => { + console.error("Auth state subscription error:", error); + }, + }); + const handleDownload = async (modelId: string, event?: React.MouseEvent) => { if (event) { event.preventDefault(); @@ -288,6 +337,17 @@ export default function SpeechTab() { }; const handleSelectModel = async (modelId: string) => { + // Check if this is a cloud model + const model = availableModels.find((m) => m.id === modelId); + const isCloudModel = model?.provider === "Amical Cloud"; + + // If cloud model and not authenticated, show login dialog + if (isCloudModel && !isAuthenticatedQuery.data) { + setPendingCloudModel(modelId); + setShowLoginDialog(true); + return; + } + try { await setSelectedModelMutation.mutateAsync({ modelId }); } catch (err) { @@ -296,6 +356,18 @@ export default function SpeechTab() { } }; + const handleLogin = async () => { + try { + await loginMutation.mutateAsync(); + setShowLoginDialog(false); + toast.info("Complete login in your browser"); + // Auth state subscription will handle the rest when login completes + } catch (err) { + console.error("Failed to login:", err); + toast.error("Failed to start login"); + } + }; + // Loading state const loading = availableModelsQuery.isLoading || @@ -352,6 +424,14 @@ export default function SpeechTab() { const progress = downloadProgress[model.id]; const isDownloading = progress?.status === "downloading"; + const isCloudModel = model.provider === "Amical Cloud"; + const isAuthenticated = + isAuthenticatedQuery.data || false; + + // Cloud models can be selected if authenticated, local models need to be downloaded + const canSelect = isCloudModel + ? isAuthenticated + : isDownloaded && isTranscriptionAvailable; return (