feat: add optional cloud models for transcrption

This commit is contained in:
haritabh-z01 2025-11-11 19:55:11 +05:30
parent 274e4b7562
commit 55e6971781
28 changed files with 1714 additions and 131 deletions

View file

@ -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 '....'

34
apps/desktop/.env.example Normal file
View file

@ -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

View file

@ -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"
? {}

View file

@ -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<NodeJS.Timeout | null>(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 (
<SidebarMenuItem>
<SidebarMenuButton disabled>
<Loader2 className="h-4 w-4 animate-spin" />
<span>Loading...</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}
if (isAuthenticated) {
const initials = getInitials(userEmail, userName);
const displayName = userName || userEmail || "Account";
return (
<SidebarMenuItem>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<SidebarMenuButton disabled={isLoading}>
<Avatar className="h-4 w-4">
<AvatarFallback className="text-[10px]">
{initials}
</AvatarFallback>
</Avatar>
<span>{displayName}</span>
</SidebarMenuButton>
</DropdownMenuTrigger>
<DropdownMenuContent className="w-56" align="end" forceMount>
<DropdownMenuLabel className="font-normal">
<div className="flex flex-col space-y-1">
{userName && (
<p className="text-sm font-medium leading-none">{userName}</p>
)}
{userEmail && (
<p className="text-xs leading-none text-muted-foreground">
{userEmail}
</p>
)}
</div>
</DropdownMenuLabel>
<DropdownMenuSeparator />
<DropdownMenuItem
onClick={handleLogout}
disabled={isLoading}
className="text-destructive focus:text-destructive"
>
{isLoading ? (
<Loader2 className="mr-2 h-4 w-4 animate-spin" />
) : (
<LogOut className="mr-2 h-4 w-4" />
)}
Sign Out
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
</SidebarMenuItem>
);
}
return (
<SidebarMenuItem>
<SidebarMenuButton onClick={handleLogin} disabled={isLoading}>
{isLoading ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<LogIn className="h-4 w-4" />
)}
<span>Sign In</span>
</SidebarMenuButton>
</SidebarMenuItem>
);
}

View file

@ -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({
</SidebarMenuButton>
</SidebarMenuItem>
))}
<AuthButton />
</SidebarMenu>
</SidebarGroupContent>
</SidebarGroup>

View file

@ -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));

View file

@ -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",

View file

@ -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

View file

@ -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<void> {
await this.initializeDatabase();

View file

@ -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();

View file

@ -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");
}
}
}

View file

@ -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<void> {
// 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,

View file

@ -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<string> {
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<string> {
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<string> {
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<string> {
// 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;
}
}
}

View file

@ -6,7 +6,6 @@ import { NavSecondary } from "@/components/nav-secondary";
import {
Sidebar,
SidebarContent,
SidebarFooter,
SidebarHeader,
SidebarMenu,
SidebarMenuButton,
@ -78,7 +77,6 @@ export function SettingsSidebar({
<NavMain items={data.navMain} />
<NavSecondary items={data.navSecondary} className="mt-auto" />
</SidebarContent>
<SidebarFooter></SidebarFooter>
</Sidebar>
);
}

View file

@ -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({

View file

@ -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<string | null>(null);
const [showLoginDialog, setShowLoginDialog] = useState(false);
const [pendingCloudModel, setPendingCloudModel] = useState<string | null>(
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 (
<TableRow
@ -363,9 +443,7 @@ export default function SpeechTab() {
<RadioGroupItem
value={model.id}
id={model.id}
disabled={
!isDownloaded || !isTranscriptionAvailable
}
disabled={!canSelect}
/>
<div>
<Label
@ -422,63 +500,89 @@ export default function SpeechTab() {
</TableCell>
<TableCell>
<div className="flex flex-col items-center space-y-1">
{!isDownloaded && !isDownloading && (
<button
onClick={(e) => handleDownload(model.id, e)}
className="w-8 h-8 rounded-full bg-muted hover:bg-muted/80 flex items-center justify-center text-primary-foreground transition-colors"
title="Click to download"
>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
)}
{!isDownloaded && isDownloading && (
<div className="relative">
<button
type="button"
onClick={(e) =>
handleCancelDownload(model.id, e)
}
className="w-8 h-8 rounded-full bg-orange-500 hover:bg-orange-600 flex items-center justify-center text-white transition-colors"
title="Click to cancel download"
aria-label={`Cancel downloading ${model.name}`}
>
<Square className="w-4 h-4" />
</button>
{/* Circular Progress Ring */}
{progress && (
<svg
className="absolute inset-0 w-8 h-8 -rotate-90 pointer-events-none"
viewBox="0 0 36 36"
{/* Cloud models show cloud icon or login button */}
{isCloudModel && (
<>
{isAuthenticated ? (
<div className="w-8 h-8 rounded-full bg-blue-500/10 flex items-center justify-center">
<Cloud className="w-4 h-4 text-blue-500" />
</div>
) : (
<button
onClick={() => setShowLoginDialog(true)}
className="w-8 h-8 rounded-full bg-blue-500 hover:bg-blue-600 flex items-center justify-center text-white transition-colors"
title="Sign in to use cloud model"
>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="100 100"
className="text-muted-foreground/30"
/>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${Math.max(0, Math.min(100, progress.progress))} 100`}
strokeLinecap="round"
className="text-white transition-all duration-300"
/>
</svg>
<LogIn className="w-4 h-4" />
</button>
)}
</div>
</>
)}
{isDownloaded && (
{/* Local models show download/delete buttons */}
{!isCloudModel &&
!isDownloaded &&
!isDownloading && (
<button
onClick={(e) =>
handleDownload(model.id, e)
}
className="w-8 h-8 rounded-full bg-muted hover:bg-muted/80 flex items-center justify-center text-primary-foreground transition-colors"
title="Click to download"
>
<Download className="w-4 h-4 text-muted-foreground" />
</button>
)}
{!isCloudModel &&
!isDownloaded &&
isDownloading && (
<div className="relative">
<button
type="button"
onClick={(e) =>
handleCancelDownload(model.id, e)
}
className="w-8 h-8 rounded-full bg-orange-500 hover:bg-orange-600 flex items-center justify-center text-white transition-colors"
title="Click to cancel download"
aria-label={`Cancel downloading ${model.name}`}
>
<Square className="w-4 h-4" />
</button>
{/* Circular Progress Ring */}
{progress && (
<svg
className="absolute inset-0 w-8 h-8 -rotate-90 pointer-events-none"
viewBox="0 0 36 36"
>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray="100 100"
className="text-muted-foreground/30"
/>
<circle
cx="18"
cy="18"
r="15.9155"
fill="none"
stroke="currentColor"
strokeWidth="3"
strokeDasharray={`${Math.max(0, Math.min(100, progress.progress))} 100`}
strokeLinecap="round"
className="text-white transition-all duration-300"
/>
</svg>
)}
</div>
)}
{!isCloudModel && isDownloaded && (
<button
type="button"
onClick={() => handleDeleteClick(model.id)}
@ -530,6 +634,47 @@ export default function SpeechTab() {
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<Dialog open={showLoginDialog} onOpenChange={setShowLoginDialog}>
<DialogContent>
<DialogHeader>
<DialogTitle>Sign In Required</DialogTitle>
<DialogDescription>
To use Amical Cloud transcription, you need to sign in with your
Amical account. This enables secure cloud-based transcription with
high accuracy.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
<p className="text-sm text-muted-foreground">
After clicking "Sign In", you'll be redirected to your browser to
complete the login process.
</p>
<div className="flex items-center space-x-2 text-sm">
<Cloud className="w-4 h-4 text-blue-500" />
<span>Fast, accurate cloud transcription</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setShowLoginDialog(false)}>
Cancel
</Button>
<Button onClick={handleLogin} disabled={loginMutation.isPending}>
{loginMutation.isPending ? (
<>
<Loader2 className="w-4 h-4 mr-2 animate-spin" />
Opening Browser...
</>
) : (
<>
<LogIn className="w-4 h-4 mr-2" />
Sign In
</>
)}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</>
);
}

View file

@ -1,4 +1,3 @@
/* eslint-disable @typescript-eslint/no-explicit-any */
import React from "react";
import { createRoot } from "react-dom/client";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

View file

@ -0,0 +1,434 @@
import { shell } from "electron";
import { randomBytes, createHash } from "crypto";
import { logger } from "../main/logger";
import { EventEmitter } from "events";
import { getSettingsSection, updateSettingsSection } from "../db/app-settings";
import { getUserAgent } from "../utils/http-client";
interface AuthConfig {
clientId: string;
authorizationEndpoint: string;
tokenEndpoint: string;
redirectUri: string;
}
export interface AuthState {
isAuthenticated: boolean;
idToken: string | null;
refreshToken: string | null;
expiresAt: number | null;
userInfo?: {
sub: string;
email?: string;
name?: string;
};
}
interface PendingAuth {
state: string;
codeVerifier: string;
codeChallenge: string;
}
interface TokenResponse {
access_token: string;
token_type: string;
expires_in: number;
refresh_token: string;
scope: string;
id_token: string;
}
export class AuthService extends EventEmitter {
private static instance: AuthService | null = null;
private config: AuthConfig;
private pendingAuth: PendingAuth | null = null;
private refreshPromise: Promise<void> | null = null;
private constructor() {
super();
this.config = {
clientId: process.env.AUTH_CLIENT_ID || "amical-desktop",
authorizationEndpoint:
process.env.AUTHORIZATION_ENDPOINT ||
"https://core.amical.ai/api/auth/oauth2/authorize",
tokenEndpoint:
process.env.AUTH_TOKEN_ENDPOINT ||
"https://core.amical.ai/api/auth/oauth2/token",
redirectUri: "amical://oauth/callback",
};
logger.main.info("AuthService initialized with config:", {
clientId: this.config.clientId,
authorizationEndpoint: this.config.authorizationEndpoint,
redirectUri: this.config.redirectUri,
});
}
static getInstance(): AuthService {
if (!AuthService.instance) {
AuthService.instance = new AuthService();
}
return AuthService.instance;
}
/**
* Generate PKCE challenge and verifier
*/
private generatePKCE(): { verifier: string; challenge: string } {
const verifier = this.base64URLEncode(randomBytes(32));
const challenge = this.base64URLEncode(
createHash("sha256").update(verifier).digest(),
);
return { verifier, challenge };
}
/**
* Base64 URL encode (no padding)
*/
private base64URLEncode(buffer: Buffer): string {
return buffer
.toString("base64")
.replace(/\+/g, "-")
.replace(/\//g, "_")
.replace(/=/g, "");
}
/**
* Generate random state for OAuth
*/
private generateState(): string {
return this.base64URLEncode(randomBytes(16));
}
/**
* Start the OAuth login flow
*/
async login(): Promise<void> {
try {
// Generate PKCE parameters
const { verifier, challenge } = this.generatePKCE();
const state = this.generateState();
// Store pending auth data
this.pendingAuth = {
state,
codeVerifier: verifier,
codeChallenge: challenge,
};
// Build authorization URL
const params = new URLSearchParams({
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
response_type: "code",
scope: "openid profile email offline_access",
state: state,
code_challenge: challenge,
code_challenge_method: "S256",
});
const authUrl = `${this.config.authorizationEndpoint}?${params.toString()}`;
logger.main.info("Starting OAuth flow with URL:", authUrl);
// Open in default browser
await shell.openExternal(authUrl);
// The callback will be handled via deep link
} catch (error) {
logger.main.error("Error starting OAuth flow:", error);
throw error;
}
}
/**
* Handle OAuth callback from deep link
*/
async handleAuthCallback(code: string, state: string | null): Promise<void> {
try {
logger.main.info("Handling auth callback");
// Validate state
if (!this.pendingAuth) {
throw new Error("No pending authentication request");
}
if (state !== this.pendingAuth.state) {
throw new Error("State mismatch - possible CSRF attack");
}
// Exchange code for token
const tokenResponse = await this.exchangeCodeForToken(
code,
this.pendingAuth.codeVerifier,
);
// Store auth data
const authState: AuthState = {
isAuthenticated: true,
idToken: tokenResponse.id_token,
refreshToken: tokenResponse.refresh_token,
expiresAt: Date.now() + tokenResponse.expires_in * 1000,
};
// Decode ID token to get user info (basic JWT decode)
if (tokenResponse.id_token) {
try {
const payload = tokenResponse.id_token.split(".")[1];
const decoded = JSON.parse(Buffer.from(payload, "base64").toString());
authState.userInfo = {
sub: decoded.sub,
email: decoded.email,
name: decoded.name,
};
} catch (error) {
logger.main.error("Error decoding ID token:", error);
}
}
// Save to database
await updateSettingsSection("auth", authState);
// Clear pending auth
this.pendingAuth = null;
// Emit success event
this.emit("authenticated", authState);
logger.main.info("Authentication successful", {
userInfo: authState.userInfo,
});
} catch (error) {
logger.main.error("Error handling auth callback:", error);
this.emit("auth-error", error);
throw error;
}
}
/**
* Exchange authorization code for tokens
*/
private async exchangeCodeForToken(
code: string,
codeVerifier: string,
): Promise<TokenResponse> {
logger.main.info(
"Exchanging code for token at:",
this.config.tokenEndpoint,
);
const body = {
grant_type: "authorization_code",
code: code,
client_id: this.config.clientId,
redirect_uri: this.config.redirectUri,
code_verifier: codeVerifier,
};
try {
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": getUserAgent(),
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
logger.main.error("Token exchange failed:", {
status: response.status,
statusText: response.statusText,
error: errorText,
});
throw new Error(`Token exchange failed: ${response.statusText}`);
}
const tokenResponse: TokenResponse = await response.json();
logger.main.debug("Token exchange successful", tokenResponse);
return tokenResponse;
} catch (error) {
logger.main.error("Error exchanging code for token:", error);
throw error;
}
}
/**
* Logout and clear auth state
*/
async logout(): Promise<void> {
await updateSettingsSection("auth", undefined);
this.emit("logged-out");
logger.main.info("User logged out");
}
/**
* Check if user is authenticated
*/
async isAuthenticated(): Promise<boolean> {
const authState = await this.getAuthState();
if (!authState || !authState.isAuthenticated) {
return false;
}
// Check if token is expired
if (authState.expiresAt && authState.expiresAt < Date.now()) {
// Token expired, should refresh
return false;
}
return true;
}
/**
* Get current auth state
*/
async getAuthState(): Promise<AuthState | null> {
const auth = await getSettingsSection("auth");
return auth as AuthState | null;
}
/**
* Get ID token for API requests
* Automatically refreshes the token if it's expiring soon
*/
async getIdToken(): Promise<string | null> {
// Refresh token if needed before returning
try {
await this.refreshTokenIfNeeded();
} catch (error) {
// If refresh fails, still try to return the current token
// The API call will fail with 401 and trigger the retry logic
logger.main.warn("Failed to refresh token in getIdToken:", error);
}
const authState = await this.getAuthState();
return authState?.idToken || null;
}
/**
* Refresh token if needed
*/
async refreshTokenIfNeeded(): Promise<void> {
// If a refresh is already in progress, wait for it
if (this.refreshPromise) {
logger.main.debug("Refresh already in progress, waiting...");
return this.refreshPromise;
}
const authState = await this.getAuthState();
if (!authState || !authState.refreshToken) {
throw new Error("No refresh token available");
}
// Check if token needs refresh (5 minutes before expiry)
if (
authState.expiresAt &&
authState.expiresAt - Date.now() > 5 * 60 * 1000
) {
// Token still valid
return;
}
// Start refresh and store the promise
logger.main.info("Token needs refresh, starting refresh flow");
this.refreshPromise = this.performTokenRefresh(
authState.refreshToken,
).finally(() => {
this.refreshPromise = null;
});
return this.refreshPromise;
}
/**
* Perform the actual token refresh API call
*/
private async performTokenRefresh(refreshToken: string): Promise<void> {
try {
logger.main.info("Refreshing access token");
const body = {
grant_type: "refresh_token",
refresh_token: refreshToken,
client_id: this.config.clientId,
};
const response = await fetch(this.config.tokenEndpoint, {
method: "POST",
headers: {
"Content-Type": "application/json",
"User-Agent": getUserAgent(),
},
body: JSON.stringify(body),
});
if (!response.ok) {
const errorText = await response.text();
logger.main.error("Token refresh failed:", {
status: response.status,
statusText: response.statusText,
error: errorText,
});
// If refresh token is invalid/expired, logout the user
if (response.status === 400 || response.status === 401) {
logger.main.info("Refresh token invalid or expired, logging out");
await this.logout();
this.emit("token-refresh-failed", new Error("Refresh token expired"));
throw new Error("Refresh token expired - please log in again");
}
throw new Error(`Token refresh failed: ${response.statusText}`);
}
const tokenResponse: TokenResponse = await response.json();
logger.main.info("Token refresh successful");
// Get current auth state to preserve user info
const currentAuthState = await this.getAuthState();
// Update auth state with new tokens
const updatedAuthState: AuthState = {
isAuthenticated: true,
idToken: tokenResponse.id_token,
// Use new refresh token if provided, otherwise keep the old one
refreshToken: tokenResponse.refresh_token || refreshToken,
expiresAt: Date.now() + tokenResponse.expires_in * 1000,
userInfo: currentAuthState?.userInfo,
};
// Update ID token user info if present
if (tokenResponse.id_token) {
try {
const payload = tokenResponse.id_token.split(".")[1];
const decoded = JSON.parse(Buffer.from(payload, "base64").toString());
updatedAuthState.userInfo = {
sub: decoded.sub,
email: decoded.email,
name: decoded.name,
};
} catch (error) {
logger.main.error("Error decoding refreshed ID token:", error);
}
}
// Save to database
await updateSettingsSection("auth", updatedAuthState);
// Emit success event
this.emit("token-refreshed", updatedAuthState);
logger.main.debug("Token refresh completed, new expiration:", {
expiresAt: new Date(updatedAuthState.expiresAt!).toISOString(),
});
} catch (error) {
logger.main.error("Error refreshing token:", error);
this.emit("token-refresh-failed", error);
throw error;
}
}
}

View file

@ -30,7 +30,9 @@ import {
OllamaModel,
} from "../types/providers";
import { SettingsService } from "./settings-service";
import { AuthService } from "./auth-service";
import { logger } from "../main/logger";
import { getUserAgent } from "../utils/http-client";
// Type for models fetched from external APIs
type FetchedModel = Pick<DBModel, "id" | "name" | "provider"> &
@ -126,10 +128,69 @@ class ModelManagerService extends EventEmitter {
removed: syncResult.removed,
});
// Restore selected model from settings
// Restore selected model from settings and validate availability
const savedSelection = await this.settingsService.getDefaultSpeechModel();
if (!savedSelection) {
if (savedSelection) {
// Validate the saved selection is still available
const availableModel = AVAILABLE_MODELS.find(
(m) => m.id === savedSelection,
);
// Check if it's a cloud model and user is authenticated
if (availableModel?.setup === "cloud") {
const authService = AuthService.getInstance();
const isAuthenticated = await authService.isAuthenticated();
if (!isAuthenticated) {
// Cloud model selected but not authenticated - auto-switch to local model
const downloadedModels = await this.getValidDownloadedModels();
const downloadedModelIds = Object.keys(downloadedModels);
if (downloadedModelIds.length > 0) {
const preferredOrder = [
"whisper-large-v3-turbo",
"whisper-large-v3",
"whisper-medium",
"whisper-small",
"whisper-base",
"whisper-tiny",
];
let newModelId = downloadedModelIds[0];
for (const candidateId of preferredOrder) {
if (downloadedModels[candidateId]) {
newModelId = candidateId;
break;
}
}
await this.settingsService.setDefaultSpeechModel(newModelId);
this.emit(
"selection-changed",
savedSelection,
newModelId,
"manual",
"speech",
);
logger.main.info(
"Auto-switched from cloud model to local model on startup (not authenticated)",
{
from: savedSelection,
to: newModelId,
},
);
} else {
// No local models available
await this.settingsService.setDefaultSpeechModel(undefined);
logger.main.warn(
"Cleared cloud model selection on startup - not authenticated and no local models available",
);
}
}
}
} else {
// No saved selection, check if we have downloaded models to auto-select
const downloadedModels = await this.getValidDownloadedModels();
const downloadedModelCount = Object.keys(downloadedModels).length;
@ -138,7 +199,7 @@ class ModelManagerService extends EventEmitter {
// Auto-select the best available model using the preferred order
const preferredOrder = [
"whisper-large-v3-turbo",
"whisper-large-v1",
"whisper-large-v3",
"whisper-medium",
"whisper-small",
"whisper-base",
@ -167,6 +228,9 @@ class ModelManagerService extends EventEmitter {
// Validate all default models after sync
await this.validateAndClearInvalidDefaults();
// Setup auth event listeners
this.setupAuthEventListeners();
} catch (error) {
logger.main.error("Error initializing model manager", {
error: error instanceof Error ? error.message : String(error),
@ -174,6 +238,85 @@ class ModelManagerService extends EventEmitter {
}
}
// Setup auth event listeners to handle logout
private setupAuthEventListeners(): void {
const authService = AuthService.getInstance();
authService.on("logged-out", async () => {
try {
const selectedModelId = await this.getSelectedModel();
if (selectedModelId) {
// Check if the selected model is a cloud model
const availableModel = AVAILABLE_MODELS.find(
(m) => m.id === selectedModelId,
);
if (availableModel?.setup === "cloud") {
// Cloud model selected but user logged out - auto-switch to first downloaded local model
const downloadedModels = await this.getValidDownloadedModels();
const downloadedModelIds = Object.keys(downloadedModels);
if (downloadedModelIds.length > 0) {
// Find the best local model from preferred order
const preferredOrder = [
"whisper-large-v3-turbo",
"whisper-large-v3",
"whisper-medium",
"whisper-small",
"whisper-base",
"whisper-tiny",
];
let newModelId = downloadedModelIds[0]; // Fallback to first available
for (const candidateId of preferredOrder) {
if (downloadedModels[candidateId]) {
newModelId = candidateId;
break;
}
}
await this.settingsService.setDefaultSpeechModel(newModelId);
this.emit(
"selection-changed",
selectedModelId,
newModelId,
"manual",
"speech",
);
logger.main.info(
"Auto-switched from cloud model to local model after logout",
{
from: selectedModelId,
to: newModelId,
},
);
} else {
// No local models available, clear selection
await this.settingsService.setDefaultSpeechModel(undefined);
this.emit(
"selection-changed",
selectedModelId,
null,
"cleared",
"speech",
);
logger.main.warn(
"Cleared cloud model selection after logout - no local models available",
);
}
}
}
} catch (error) {
logger.main.error("Error handling logout in model manager", {
error: error instanceof Error ? error.message : String(error),
});
}
});
}
private ensureModelsDirectory(): void {
if (!fs.existsSync(this.modelsDirectory)) {
fs.mkdirSync(this.modelsDirectory, { recursive: true });
@ -262,6 +405,9 @@ class ModelManagerService extends EventEmitter {
const response = await fetch(model.downloadUrl, {
signal: abortController.signal,
headers: {
"User-Agent": getUserAgent(),
},
});
if (!response.ok) {
@ -546,9 +692,25 @@ class ModelManagerService extends EventEmitter {
// If setting to a specific model, validate it exists
if (modelId) {
const downloadedModels = await this.getValidDownloadedModels();
if (!downloadedModels[modelId]) {
throw new Error(`Model not downloaded: ${modelId}`);
// Check if it's a cloud model
const availableModel = AVAILABLE_MODELS.find((m) => m.id === modelId);
if (availableModel?.setup === "cloud") {
// Cloud model - check authentication
const authService = AuthService.getInstance();
const isAuthenticated = await authService.isAuthenticated();
if (!isAuthenticated) {
throw new Error("Authentication required for cloud models");
}
logger.main.info("Selecting cloud model", { modelId });
} else {
// Offline model - must be downloaded
const downloadedModels = await this.getValidDownloadedModels();
if (!downloadedModels[modelId]) {
throw new Error(`Model not downloaded: ${modelId}`);
}
}
}
@ -630,6 +792,7 @@ class ModelManagerService extends EventEmitter {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"User-Agent": getUserAgent(),
},
});
@ -665,6 +828,7 @@ class ModelManagerService extends EventEmitter {
method: "GET",
headers: {
"Content-Type": "application/json",
"User-Agent": getUserAgent(),
},
});
@ -697,6 +861,7 @@ class ModelManagerService extends EventEmitter {
headers: {
Authorization: `Bearer ${apiKey}`,
"Content-Type": "application/json",
"User-Agent": getUserAgent(),
},
});
@ -755,6 +920,7 @@ class ModelManagerService extends EventEmitter {
method: "GET",
headers: {
"Content-Type": "application/json",
"User-Agent": getUserAgent(),
},
});

View file

@ -2,9 +2,11 @@ import {
PipelineContext,
StreamingPipelineContext,
StreamingSession,
TranscriptionProvider,
} from "../pipeline/core/pipeline-types";
import { createDefaultContext } from "../pipeline/core/context";
import { WhisperProvider } from "../pipeline/providers/transcription/whisper-provider";
import { AmicalCloudProvider } from "../pipeline/providers/transcription/amical-cloud-provider";
import { OpenRouterProvider } from "../pipeline/providers/formatting/openrouter-formatter";
import { ModelManagerService } from "../services/model-manager";
import { SettingsService } from "../services/settings-service";
@ -16,12 +18,15 @@ import { v4 as uuid } from "uuid";
import { VADService } from "./vad-service";
import { Mutex } from "async-mutex";
import { app, dialog } from "electron";
import { AVAILABLE_MODELS } from "../constants/models";
/**
* Service for audio transcription and optional formatting
*/
export class TranscriptionService {
private whisperProvider: WhisperProvider;
private cloudProvider: AmicalCloudProvider;
private currentProvider: TranscriptionProvider | null = null;
private openRouterProvider: OpenRouterProvider | null = null;
private formatterEnabled = false;
private streamingSessions = new Map<string, StreamingSession>();
@ -40,6 +45,7 @@ export class TranscriptionService {
telemetryService: TelemetryService,
) {
this.whisperProvider = new WhisperProvider(modelManagerService);
this.cloudProvider = new AmicalCloudProvider();
this.vadService = vadService;
this.settingsService = settingsService;
this.vadMutex = new Mutex();
@ -48,6 +54,32 @@ export class TranscriptionService {
this.modelManagerService = modelManagerService;
}
/**
* Select the appropriate transcription provider based on the selected model
*/
private async selectProvider(): Promise<TranscriptionProvider> {
const selectedModelId = await this.modelManagerService.getSelectedModel();
if (!selectedModelId) {
// Default to whisper if no model selected
this.currentProvider = this.whisperProvider;
return this.whisperProvider;
}
// Find the model in AVAILABLE_MODELS
const model = AVAILABLE_MODELS.find((m) => m.id === selectedModelId);
// Use cloud provider for Amical Cloud models
if (model?.provider === "Amical Cloud") {
this.currentProvider = this.cloudProvider;
return this.cloudProvider;
}
// Default to whisper for all other models
this.currentProvider = this.whisperProvider;
return this.whisperProvider;
}
async initialize(): Promise<void> {
if (this.vadService) {
logger.transcription.info("Using VAD service");
@ -55,38 +87,53 @@ export class TranscriptionService {
logger.transcription.warn("VAD service not available");
}
// Check if we should preload Whisper model
const transcriptionSettings =
await this.settingsService.getTranscriptionSettings();
const shouldPreload = transcriptionSettings?.preloadWhisperModel !== false; // Default to true
// Check if the selected model is a cloud model
const selectedModelId = await this.modelManagerService.getSelectedModel();
const model = selectedModelId
? AVAILABLE_MODELS.find((m) => m.id === selectedModelId)
: null;
const isCloudModel = model?.provider === "Amical Cloud";
if (shouldPreload) {
// Check if models are available for preloading
const hasModels = await this.isModelAvailable();
if (hasModels) {
logger.transcription.info("Preloading Whisper model...");
await this.preloadWhisperModel();
this.modelWasPreloaded = true;
logger.transcription.info("Whisper model preloaded successfully");
} else {
logger.transcription.info(
"Whisper model preloading skipped - no models available",
);
if (app.isReady()) {
setTimeout(() => {
dialog.showMessageBox({
type: "warning",
title: "No Transcription Models",
message: "No transcription models are available.",
detail:
"To use voice transcription, please download a model from Speech Models.",
buttons: ["OK"],
});
}, 2000); // Delay to ensure windows are ready
// Only preload for local models
if (!isCloudModel) {
// Check if we should preload Whisper model
const transcriptionSettings =
await this.settingsService.getTranscriptionSettings();
const shouldPreload =
transcriptionSettings?.preloadWhisperModel !== false; // Default to true
if (shouldPreload) {
// Check if models are available for preloading
const hasModels = await this.isModelAvailable();
if (hasModels) {
logger.transcription.info("Preloading Whisper model...");
await this.preloadWhisperModel();
this.modelWasPreloaded = true;
logger.transcription.info("Whisper model preloaded successfully");
} else {
logger.transcription.info(
"Whisper model preloading skipped - no models available",
);
if (app.isReady() && !isCloudModel) {
setTimeout(() => {
dialog.showMessageBox({
type: "warning",
title: "No Transcription Models",
message: "No transcription models are available.",
detail:
"To use voice transcription, please download a model from Speech Models or use a cloud model.",
buttons: ["OK"],
});
}, 2000); // Delay to ensure windows are ready
}
}
} else {
logger.transcription.info("Whisper model preloading disabled");
}
} else {
logger.transcription.info("Whisper model preloading disabled");
logger.transcription.info(
"Using cloud model - skipping local model preload",
);
}
logger.transcription.info("Transcription service initialized");
@ -268,17 +315,28 @@ export class TranscriptionService {
.join(" ")
.trim();
const chunkTranscription = await this.whisperProvider.transcribe({
audioData: audioChunk,
speechProbability: speechProbability, // Now from VAD service
context: {
vocabulary: session.context.sharedData.vocabulary,
accessibilityContext: session.context.sharedData.accessibilityContext,
previousChunk,
aggregatedTranscription: aggregatedTranscription || undefined,
},
flush: isFinal,
});
// Select the appropriate provider
const provider = await this.selectProvider();
// For providers that support flush, call it separately when final
let chunkTranscription = "";
if (isFinal && provider.flush) {
// If final chunk, flush the provider buffer
chunkTranscription = await provider.flush();
} else {
// Normal transcription
chunkTranscription = await provider.transcribe({
audioData: audioChunk,
speechProbability: speechProbability, // Now from VAD service
context: {
vocabulary: session.context.sharedData.vocabulary,
accessibilityContext: session.context.sharedData.accessibilityContext,
previousChunk,
aggregatedTranscription: aggregatedTranscription || undefined,
},
});
}
// Accumulate the result only if Whisper returned something
// (it returns empty string while buffering)

View file

@ -7,6 +7,7 @@ import { updaterRouter } from "./routers/updater";
import { recordingRouter } from "./routers/recording";
import { widgetRouter } from "./routers/widget";
import { notesRouter } from "./routers/notes";
import { authRouter } from "./routers/auth";
import { createRouter, procedure } from "./trpc";
export const router = createRouter({
@ -57,6 +58,9 @@ export const router = createRouter({
// Notes router
notes: notesRouter,
// Auth router
auth: authRouter,
});
export type AppRouter = typeof router;

View file

@ -0,0 +1,137 @@
import { observable } from "@trpc/server/observable";
import { createRouter, procedure } from "../trpc";
import { logger } from "../../main/logger";
import { AuthState } from "../../services/auth-service";
export const authRouter = createRouter({
// Get current auth status
getAuthStatus: procedure.query(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
const authState = await authService.getAuthState();
const isAuthenticated = await authService.isAuthenticated();
return {
isAuthenticated,
userEmail: authState?.userInfo?.email || null,
userName: authState?.userInfo?.name || null,
};
}),
// Initiate login flow
login: procedure.mutation(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
await authService.login();
// The actual authentication will complete via the deep link callback
return {
success: true,
message: "Login initiated - please complete in your browser",
};
}),
// Logout
logout: procedure.mutation(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
await authService.logout();
return {
success: true,
message: "Logged out successfully",
};
}),
// Check if authenticated (for UI updates)
isAuthenticated: procedure.query(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
return await authService.isAuthenticated();
}),
// Subscribe to auth state changes
// Using Observable instead of async generator due to Symbol.asyncDispose conflict
// eslint-disable-next-line deprecation/deprecation
onAuthStateChange: procedure.subscription(({ ctx }) => {
return observable<{
isAuthenticated: boolean;
userEmail: string | null;
userName: string | null;
error?: string;
}>((emit) => {
const authService = ctx.serviceManager.getService("authService")!;
// Define handlers once (not in a loop)
const handleAuthenticated = async (authState: AuthState) => {
logger.main.info("Auth state changed - authenticated");
emit.next({
isAuthenticated: true,
userEmail: authState.userInfo?.email || null,
userName: authState.userInfo?.name || null,
});
};
const handleLoggedOut = () => {
logger.main.info("Auth state changed - logged out");
emit.next({
isAuthenticated: false,
userEmail: null,
userName: null,
});
};
const handleAuthError = (error: Error) => {
logger.main.error("Auth error:", error);
emit.next({
isAuthenticated: false,
userEmail: null,
userName: null,
error: error.message,
});
};
// Attach listeners once
authService.on("authenticated", handleAuthenticated);
authService.on("logged-out", handleLoggedOut);
authService.on("auth-error", handleAuthError);
// Send initial state
authService.getAuthState().then((state) => {
emit.next({
isAuthenticated: state?.isAuthenticated || false,
userEmail: state?.userInfo?.email || null,
userName: state?.userInfo?.name || null,
});
});
// Cleanup function - removes listeners when subscription ends
return () => {
authService.off("authenticated", handleAuthenticated);
authService.off("logged-out", handleLoggedOut);
authService.off("auth-error", handleAuthError);
};
});
}),
// Check if cloud model requires auth
isCloudModelSelected: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
)!;
if (!modelManagerService) {
return false;
}
const selectedModelId = await modelManagerService.getSelectedModel();
if (!selectedModelId) {
return false;
}
// Check if it's a cloud model
const { AVAILABLE_MODELS } = await import("../../constants/models");
const model = AVAILABLE_MODELS.find((m) => m.id === selectedModelId);
return model?.provider === "Amical Cloud";
}),
});

View file

@ -16,7 +16,7 @@ export const modelsRouter = createRouter({
z.object({
provider: z.string().optional(),
type: z.enum(["speech", "language", "embedding"]).optional(),
downloadedOnly: z.boolean().optional().default(false),
selectable: z.boolean().optional().default(false),
}),
)
.query(async ({ input, ctx }): Promise<Model[]> => {
@ -29,22 +29,25 @@ export const modelsRouter = createRouter({
// For speech models (local whisper)
if (input.type === "speech") {
if (input.downloadedOnly) {
const downloadedModels =
await modelManagerService.getDownloadedModels();
return Object.values(downloadedModels);
}
// Return all available whisper models as Model type
// We need to convert from AvailableWhisperModel to Model format
const availableModels = modelManagerService.getAvailableModels();
const downloadedModels =
await modelManagerService.getDownloadedModels();
// Check authentication status for cloud model filtering
const authService = ctx.serviceManager.getService("authService")!;
const isAuthenticated = await authService.isAuthenticated();
// Map available models to Model format using downloaded data if available
return availableModels.map((m) => {
let models = availableModels.map((m) => {
const downloaded = downloadedModels[m.id];
if (downloaded) {
return downloaded;
// Include setup field from available model metadata
return {
...downloaded,
setup: m.setup,
} as Model & { setup: "offline" | "cloud" };
}
// Create a partial Model for non-downloaded models
return {
@ -64,8 +67,24 @@ export const modelsRouter = createRouter({
accuracy: m.accuracy,
createdAt: new Date(),
updatedAt: new Date(),
} as Model;
setup: m.setup,
} as Model & { setup: "offline" | "cloud" };
});
// Apply selectable filtering for dropdown/combobox
if (input.selectable) {
models = models.filter((m) => {
const model = m as Model & { setup: "offline" | "cloud" };
// Filter cloud models if not authenticated
if (model.setup === "cloud") {
return isAuthenticated;
}
// Filter local models that aren't downloaded
return model.downloadedAt !== null;
});
}
return models;
}
// For language/embedding models (provider models)

View file

@ -0,0 +1,13 @@
import { app } from "electron";
import { getPlatformDisplayName } from "./platform";
/**
* Get the User-Agent string for HTTP requests
* Format: amical-desktop/{version} ({platform})
* Example: amical-desktop/0.1.3 (macOS)
*/
export function getUserAgent(): string {
const version = app.getVersion();
const platform = getPlatformDisplayName();
return `amical-desktop/${version} (${platform})`;
}

View file

@ -7,7 +7,7 @@
"lint": "turbo run lint",
"format": "prettier --write \"**/*.{ts,tsx,md,json,mjs,mts,css,mdx}\"",
"format:check": "turbo run format:check",
"check-types": "turbo run check-types"
"type:check": "turbo run type:check"
},
"devDependencies": {
"prettier": "^3.5.3",

View file

@ -14,7 +14,7 @@
"generate:csharp": "tsx scripts/generate-csharp-models.ts",
"generate:all": "pnpm run generate:json-schemas && pnpm run generate:swift && pnpm run generate:csharp",
"lint": "eslint .",
"check-types": "tsc --noEmit"
"type:check": "tsc --noEmit"
},
"keywords": [
"amical",

View file

@ -8,7 +8,7 @@
"scripts": {
"lint": "eslint . --max-warnings 0",
"generate:component": "turbo gen react-component",
"check-types": "tsc --noEmit"
"type:check": "tsc --noEmit"
},
"devDependencies": {
"@amical/eslint-config": "workspace:*",

View file

@ -49,8 +49,8 @@
"lint": {
"dependsOn": ["format:check"]
},
"check-types": {
"dependsOn": ["^check-types"]
"type:check": {
"dependsOn": ["^type:check"]
},
"dev": {
"dependsOn": ["^build"],