feat: add optional cloud models for transcrption
This commit is contained in:
parent
274e4b7562
commit
55e6971781
28 changed files with 1714 additions and 131 deletions
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
7
.github/ISSUE_TEMPLATE/bug_report.md
vendored
|
|
@ -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
34
apps/desktop/.env.example
Normal 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
|
||||
|
|
@ -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"
|
||||
? {}
|
||||
|
|
|
|||
196
apps/desktop/src/components/auth-button.tsx
Normal file
196
apps/desktop/src/components/auth-button.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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));
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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";
|
||||
|
|
|
|||
434
apps/desktop/src/services/auth-service.ts
Normal file
434
apps/desktop/src/services/auth-service.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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(),
|
||||
},
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
137
apps/desktop/src/trpc/routers/auth.ts
Normal file
137
apps/desktop/src/trpc/routers/auth.ts
Normal 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";
|
||||
}),
|
||||
});
|
||||
|
|
@ -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)
|
||||
|
|
|
|||
13
apps/desktop/src/utils/http-client.ts
Normal file
13
apps/desktop/src/utils/http-client.ts
Normal 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})`;
|
||||
}
|
||||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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",
|
||||
|
|
|
|||
|
|
@ -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:*",
|
||||
|
|
|
|||
|
|
@ -49,8 +49,8 @@
|
|||
"lint": {
|
||||
"dependsOn": ["format:check"]
|
||||
},
|
||||
"check-types": {
|
||||
"dependsOn": ["^check-types"]
|
||||
"type:check": {
|
||||
"dependsOn": ["^type:check"]
|
||||
},
|
||||
"dev": {
|
||||
"dependsOn": ["^build"],
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue