chore: update ob copy + resolve circular deps

This commit is contained in:
haritabh-z01 2025-11-30 20:54:49 +05:30
parent 0e72fb2eb6
commit deed3c4de4
39 changed files with 939 additions and 1759 deletions

View file

@ -1,60 +0,0 @@
# Amical Desktop Application Environment Variables
# Copy this file to .env.local and update with your values
# ============================================
# ONBOARDING CONFIGURATION
# ============================================
# Force onboarding flow for all users (development/testing)
FORCE_ONBOARDING=false
# Skip specific onboarding screens (feature flags)
ONBOARDING_SKIP_WELCOME=false
ONBOARDING_SKIP_FEATURES=false
ONBOARDING_SKIP_DISCOVERY=false
ONBOARDING_SKIP_MODELS=false
# Default model selection
# DEFAULT_MODEL_TYPE=cloud
# ============================================
# ANALYTICS & TELEMETRY
# ============================================
# Enable/disable telemetry
TELEMETRY_ENABLED=true
# PostHog configuration
POSTHOG_HOST=https://app.posthog.com
POSTHOG_API_KEY=your-posthog-api-key-here
# ============================================
# DEVELOPMENT SETTINGS
# ============================================
# Environment
NODE_ENV=development
# Logging
LOG_LEVEL=info
# Mock system specs for testing (JSON string)
# MOCK_SYSTEM_SPECS='{"memory_total_gb":8,"cpu_cores":4}'
# ============================================
# BUILD & PACKAGING
# ============================================
# Skip code signing (macOS development)
SKIP_CODESIGNING=false
# Skip notarization (macOS development)
SKIP_NOTARIZATION=false
# ============================================
# NATIVE HELPERS
# ============================================
# Path to native helpers (auto-detected by default)
# SWIFT_HELPER_PATH=/path/to/swift-helper
# WINDOWS_HELPER_PATH=/path/to/windows-helper

View file

@ -137,6 +137,9 @@ export function ShortcutInput({
};
// Subscribe to key events when recording
// Note: activeKeys closure is fresh on each render because useSubscription
// updates its callback reference, so previousKeys correctly captures the
// previous state value when onData fires.
api.settings.activeKeysUpdates.useSubscription(undefined, {
enabled: isRecordingShortcut,
onData: (keys: string[]) => {

View file

@ -1,23 +1,28 @@
import { app } from "electron";
import { app, ipcMain, shell } from "electron";
import { initializeDatabase } from "../../db";
import { logger } from "../logger";
import { WindowManager } from "./window-manager";
import { setupApplicationMenu } from "../menu";
import { ServiceManager } from "../managers/service-manager";
import { EventHandlers } from "./event-handlers";
import { TrayManager } from "../managers/tray-manager";
import { createIPCHandler } from "electron-trpc-experimental/main";
import { router } from "../../trpc/router";
import { createContext } from "../../trpc/context";
import type { OnboardingService } from "../../services/onboarding-service";
import type { RecordingManager } from "../managers/recording-manager";
import type { RecordingState } from "../../types/recording";
import type { SettingsService } from "../../services/settings-service";
export class AppManager {
private windowManager: WindowManager;
private windowManager!: WindowManager;
private serviceManager: ServiceManager;
private eventHandlers: EventHandlers | null = null;
private trayManager: TrayManager;
private trpcHandler!: ReturnType<typeof createIPCHandler>;
constructor() {
this.windowManager = new WindowManager();
this.serviceManager = ServiceManager.createInstance();
this.serviceManager.setWindowManager(this.windowManager);
this.serviceManager = ServiceManager.getInstance();
this.trayManager = TrayManager.getInstance();
// WindowManager created in initialize() after deps are ready
}
handleDeepLink(url: string): void {
@ -37,9 +42,7 @@ export class AppManager {
if (code) {
// Get AuthService and complete the OAuth flow
const authService = this.serviceManager.getService("authService");
if (authService) {
authService.handleAuthCallback(code, state);
}
authService.handleAuthCallback(code, state);
}
}
@ -54,22 +57,42 @@ export class AppManager {
await this.serviceManager.initialize();
// Initialize OnboardingManager with WindowManager reference
this.serviceManager.initializeOnboardingManager(this.windowManager);
// Initialize tRPC handler (services must be ready first)
this.trpcHandler = createIPCHandler({
router,
windows: [],
createContext: async () => createContext(this.serviceManager),
});
logger.main.info("tRPC handler initialized");
// Check if onboarding is needed using OnboardingService (single source of truth)
// Create WindowManager now that all deps are ready
const settingsService = this.serviceManager.getService("settingsService");
this.windowManager = new WindowManager(settingsService, this.trpcHandler);
// Register WindowManager with ServiceManager for getService("windowManager")
this.serviceManager.setWindowManager(this.windowManager);
// Get onboarding service and subscribe to lifecycle events
const onboardingService =
this.serviceManager.getService("onboardingService");
const onboardingCheck = await onboardingService!.checkNeedsOnboarding();
this.setupOnboardingEventListeners(onboardingService);
// Subscribe to recording state changes for widget visibility
const recordingManager = this.serviceManager.getService("recordingManager");
this.setupRecordingEventListeners(recordingManager);
// Check if onboarding is needed using OnboardingService (single source of truth)
const onboardingCheck = await onboardingService.checkNeedsOnboarding();
// Sync auto-launch setting with OS on startup
const settingsService = this.serviceManager.getService("settingsService");
if (settingsService) {
settingsService.syncAutoLaunch();
logger.main.info("Auto-launch setting synced with OS");
}
settingsService.syncAutoLaunch();
logger.main.info("Auto-launch setting synced with OS");
// Subscribe to settings changes for window updates
this.setupSettingsEventListeners(settingsService);
if (onboardingCheck.needed) {
await onboardingService.startOnboardingFlow();
this.windowManager.createOrShowOnboardingWindow();
} else {
await this.setupWindows();
@ -77,13 +100,15 @@ export class AppManager {
await this.setupMenu();
// Setup event handlers
this.eventHandlers = new EventHandlers(this);
this.eventHandlers.setupEventHandlers();
// Initialize tray
this.trayManager.initialize(this.windowManager);
// Setup IPC handlers
ipcMain.handle("open-external", async (_event, url: string) => {
await shell.openExternal(url);
logger.main.debug("Opening external URL", { url });
});
// Auto-update is now handled by update-electron-app in main.ts
logger.main.info("Application initialized successfully");
@ -96,10 +121,100 @@ export class AppManager {
);
}
private setupOnboardingEventListeners(
onboardingService: OnboardingService,
): void {
// Handle onboarding completion
onboardingService.on("completed", () => {
const shouldRelaunch = process.env.NODE_ENV !== "development";
logger.main.info("Onboarding completed event received", {
shouldRelaunch,
});
this.windowManager.closeOnboardingWindow();
if (shouldRelaunch) {
// Production: relaunch app to reinitialize with new settings
logger.main.info("Relaunching app after onboarding completion");
app.relaunch();
app.quit();
} else {
// Development: just show the main app windows
logger.main.info("Dev mode: showing main app windows after onboarding");
this.setupWindows();
}
});
// Handle onboarding cancellation
onboardingService.on("cancelled", () => {
logger.main.info("Onboarding cancelled event received, quitting app");
this.windowManager.closeOnboardingWindow();
app.quit();
});
logger.main.info("Onboarding event listeners set up");
}
private setupRecordingEventListeners(
recordingManager: RecordingManager,
): void {
recordingManager.on("state-changed", (state: RecordingState) => {
this.updateWidgetVisibility(state === "idle").catch((error) => {
logger.main.error("Failed to update widget visibility", error);
});
});
logger.main.info("Recording state listener connected in AppManager");
}
private setupSettingsEventListeners(settingsService: SettingsService): void {
// Handle preference changes (widget visibility)
settingsService.on(
"preferences-changed",
async ({
showWidgetWhileInactiveChanged,
}: {
showWidgetWhileInactiveChanged: boolean;
}) => {
if (showWidgetWhileInactiveChanged) {
const recordingManager =
this.serviceManager.getService("recordingManager");
const isIdle = recordingManager.getState() === "idle";
await this.updateWidgetVisibility(isIdle);
}
},
);
// Handle theme changes
settingsService.on("theme-changed", async () => {
await this.windowManager.updateAllWindowThemes();
});
logger.main.info("Settings event listeners set up");
}
private async updateWidgetVisibility(isIdle: boolean): Promise<void> {
const settingsService = this.serviceManager.getService("settingsService");
const preferences = await settingsService.getPreferences();
if (preferences.showWidgetWhileInactive || !isIdle) {
this.windowManager.showWidget();
} else {
this.windowManager.hideWidget();
}
}
private async setupWindows(): Promise<void> {
this.windowManager.createWidgetWindow();
await this.windowManager.createWidgetWindow();
// AppManager decides initial widget visibility based on settings
const settingsService = this.serviceManager.getService("settingsService");
const preferences = await settingsService.getPreferences();
if (preferences.showWidgetWhileInactive) {
this.windowManager.showWidget();
}
this.windowManager.createOrShowMainWindow();
// tRPC handler is now set up in WindowManager when windows are created
if (app.dock) {
app.dock
@ -129,30 +244,6 @@ export class AppManager {
);
}
getWindowManager(): WindowManager {
return this.windowManager;
}
getServiceManager(): ServiceManager {
return this.serviceManager;
}
getTranscriptionService() {
return this.serviceManager.getService("transcriptionService");
}
getNativeBridge() {
return this.serviceManager.getService("nativeBridge");
}
getAutoUpdaterService() {
return this.serviceManager.getService("autoUpdaterService");
}
getEventHandlers(): EventHandlers | null {
return this.eventHandlers;
}
async cleanup(): Promise<void> {
await this.serviceManager.cleanup();
if (this.windowManager) {
@ -188,14 +279,36 @@ export class AppManager {
}
async handleActivate(): Promise<void> {
// If onboarding is in progress, just focus that window
const onboardingWindow = this.windowManager.getOnboardingWindow();
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.show();
onboardingWindow.focus();
return;
}
// Normal activation logic for main app
const allWindows = this.windowManager.getAllWindows();
if (allWindows.every((w) => !w || w.isDestroyed())) {
// All windows destroyed - recreate widget with proper visibility
await this.windowManager.createWidgetWindow();
const settingsService = this.serviceManager.getService("settingsService");
const preferences = await settingsService.getPreferences();
if (preferences.showWidgetWhileInactive) {
this.windowManager.showWidget();
}
} else {
const widgetWindow = this.windowManager.getWidgetWindow();
if (!widgetWindow || widgetWindow.isDestroyed()) {
// Widget destroyed - recreate with proper visibility
await this.windowManager.createWidgetWindow();
const settingsService =
this.serviceManager.getService("settingsService");
const preferences = await settingsService.getPreferences();
if (preferences.showWidgetWhileInactive) {
this.windowManager.showWidget();
}
} else {
widgetWindow.show();
}

View file

@ -1,98 +0,0 @@
import { HelperEvent } from "@amical/types";
import { AppManager } from "./app-manager";
import { logger } from "../logger";
import { ipcMain, shell } from "electron";
import NotesService from "../../services/notes-service";
export class EventHandlers {
private appManager: AppManager;
constructor(appManager: AppManager) {
this.appManager = appManager;
}
setupEventHandlers(): void {
this.setupNativeBridgeEventHandlers();
this.setupGeneralIPCHandlers();
this.setupNotesIPCHandlers();
// Note: Audio IPC handlers are now managed by RecordingService
// Note: Onboarding IPC handlers removed - now using tRPC
}
private setupNativeBridgeEventHandlers(): void {
try {
const nativeBridge = this.appManager.getNativeBridge();
if (!nativeBridge) {
logger.main.warn("Native bridge not available for event handlers");
return;
}
// Handle non-shortcut related events only
nativeBridge.on("helperEvent", (event: HelperEvent) => {
logger.swift.debug("Received helperEvent from native bridge", {
event,
});
// Let ShortcutManager handle all key-related events
// This handler can process other helper events if needed
});
nativeBridge.on("error", (error: Error) => {
logger.main.error("Native bridge error:", error);
});
nativeBridge.on("close", (code: number | null) => {
logger.swift.warn("Native helper process closed", { code });
});
} catch (error) {
logger.main.warn("Native bridge not available for event handlers");
}
}
private setupGeneralIPCHandlers(): void {
// Handle opening external links
ipcMain.handle("open-external", async (event, url: string) => {
await shell.openExternal(url);
logger.main.debug("Opening external URL", { url });
});
}
private setupNotesIPCHandlers(): void {
const notesService = NotesService.getInstance();
// Save yjs update
ipcMain.handle(
"notes:saveYjsUpdate",
async (event, noteId: number, update: ArrayBuffer) => {
try {
// Convert ArrayBuffer to Uint8Array
const updateArray = new Uint8Array(update);
await notesService.saveYjsUpdate(noteId, updateArray);
logger.main.debug("Saved yjs update", {
noteId,
updateSize: updateArray.length,
});
} catch (error) {
logger.main.error("Failed to save yjs update", error);
throw error;
}
},
);
// Load all yjs updates for a note
ipcMain.handle("notes:loadYjsUpdates", async (event, noteId: number) => {
try {
const updates = await notesService.loadYjsUpdates(noteId);
logger.main.debug("Loaded yjs updates", {
noteId,
count: updates.length,
});
// Convert Uint8Array[] to ArrayBuffer[] for IPC transfer
return updates.map((u) => u.buffer);
} catch (error) {
logger.main.error("Failed to load yjs updates", error);
throw error;
}
});
}
}

View file

@ -7,9 +7,8 @@ import {
} from "electron";
import path from "node:path";
import { logger } from "../logger";
import { ServiceManager } from "../managers/service-manager";
import type { RecordingManager } from "../managers/recording-manager";
import type { RecordingState } from "../../types/recording";
import type { SettingsService } from "../../services/settings-service";
import type { createIPCHandler } from "electron-trpc-experimental/main";
declare const MAIN_WINDOW_VITE_DEV_SERVER_URL: string;
declare const MAIN_WINDOW_VITE_NAME: string;
@ -24,40 +23,34 @@ export class WindowManager {
private cursorPollingInterval: NodeJS.Timeout | null = null;
private themeListenerSetup: boolean = false;
constructor(
private settingsService: SettingsService,
private trpcHandler: ReturnType<typeof createIPCHandler>,
) {
logger.main.info("WindowManager created with dependencies");
}
private async getThemeColors(): Promise<{
backgroundColor: string;
symbolColor: string;
}> {
try {
const settingsService =
ServiceManager.getInstance()?.getService("settingsService");
if (!settingsService) {
// Default to light theme if service unavailable
return { backgroundColor: "#ffffff", symbolColor: "#000000" };
}
const uiSettings = await this.settingsService.getUISettings();
const theme = uiSettings?.theme || "system";
const uiSettings = await settingsService.getUISettings();
const theme = uiSettings?.theme || "system";
// Determine if we should use dark colors
let isDark = false;
if (theme === "dark") {
isDark = true;
} else if (theme === "light") {
isDark = false;
} else if (theme === "system") {
isDark = nativeTheme.shouldUseDarkColors;
}
// Return appropriate colors
return isDark
? { backgroundColor: "#171717", symbolColor: "#fafafa" }
: { backgroundColor: "#ffffff", symbolColor: "#171717" };
} catch (error) {
logger.main.error("Failed to get theme colors:", error);
// Default to light theme on error
return { backgroundColor: "#ffffff", symbolColor: "#000000" };
// Determine if we should use dark colors
let isDark = false;
if (theme === "dark") {
isDark = true;
} else if (theme === "light") {
isDark = false;
} else if (theme === "system") {
isDark = nativeTheme.shouldUseDarkColors;
}
// Return appropriate colors
return isDark
? { backgroundColor: "#171717", symbolColor: "#fafafa" }
: { backgroundColor: "#ffffff", symbolColor: "#171717" };
}
async updateAllWindowThemes(): Promise<void> {
@ -83,10 +76,7 @@ export class WindowManager {
// Listen for system theme changes
nativeTheme.on("updated", async () => {
const settingsService =
ServiceManager.getInstance()!.getService("settingsService")!;
const uiSettings = await settingsService.getUISettings();
const uiSettings = await this.settingsService.getUISettings();
const theme = uiSettings?.theme || "system";
// Only update if theme is set to "system"
@ -142,9 +132,7 @@ export class WindowManager {
this.mainWindow.on("close", () => {
// Detach window before it's destroyed
ServiceManager.getInstance()!
.getTRPCHandler()!
.detachWindow(this.mainWindow!);
this.trpcHandler.detachWindow(this.mainWindow!);
});
this.mainWindow.on("closed", () => {
@ -152,9 +140,7 @@ export class WindowManager {
this.mainWindow = null;
});
ServiceManager.getInstance()!
.getTRPCHandler()!
.attachWindow(this.mainWindow!);
this.trpcHandler.attachWindow(this.mainWindow!);
}
async createWidgetWindow(): Promise<void> {
@ -171,7 +157,6 @@ export class WindowManager {
width,
height,
frame: false,
titleBarStyle: "hidden",
transparent: true,
alwaysOnTop: true,
resizable: false,
@ -212,9 +197,7 @@ export class WindowManager {
this.widgetWindow.on("close", () => {
// Detach window before it's destroyed
ServiceManager.getInstance()!
.getTRPCHandler()!
.detachWindow(this.widgetWindow!);
this.trpcHandler.detachWindow(this.widgetWindow!);
});
this.widgetWindow.on("closed", () => {
@ -234,22 +217,11 @@ export class WindowManager {
this.setupDisplayChangeNotifications();
// Update tRPC handler with new window
ServiceManager.getInstance()!
.getTRPCHandler()!
.attachWindow(this.widgetWindow!);
this.trpcHandler.attachWindow(this.widgetWindow!);
// Check preference to determine initial visibility
const settingsService =
ServiceManager.getInstance()!.getService("settingsService")!;
const preferences = await settingsService.getPreferences();
if (preferences.showWidgetWhileInactive) {
this.widgetWindow.show();
logger.main.info("Widget window shown (showWidgetWhileInactive: true)");
} else {
logger.main.info(
"Widget window created but hidden (showWidgetWhileInactive: false)",
);
}
logger.main.info(
"Widget window created (visibility controlled by AppManager)",
);
}
createOrShowOnboardingWindow(): void {
@ -264,7 +236,7 @@ export class WindowManager {
this.onboardingWindow = new BrowserWindow({
width: 800,
height: 900,
height: 928,
frame: false,
titleBarStyle: "hidden",
resizable: false,
@ -290,6 +262,10 @@ export class WindowManager {
);
}
this.onboardingWindow.on("close", () => {
this.trpcHandler.detachWindow(this.onboardingWindow!);
});
this.onboardingWindow.on("closed", () => {
this.onboardingWindow = null;
});
@ -299,6 +275,7 @@ export class WindowManager {
this.mainWindow.setEnabled(false);
}
this.trpcHandler.attachWindow(this.onboardingWindow!);
logger.main.info("Onboarding window created");
}
@ -315,47 +292,16 @@ export class WindowManager {
}
}
async updateWidgetVisibility(isIdle: boolean): Promise<void> {
if (!this.widgetWindow || this.widgetWindow.isDestroyed()) {
return;
}
const settingsService =
ServiceManager.getInstance()!.getService("settingsService")!;
const preferences = await settingsService.getPreferences();
if (preferences.showWidgetWhileInactive) {
showWidget(): void {
if (this.widgetWindow && !this.widgetWindow.isDestroyed()) {
this.widgetWindow.showInactive();
return;
}
}
if (isIdle) {
hideWidget(): void {
if (this.widgetWindow && !this.widgetWindow.isDestroyed()) {
this.widgetWindow.hide();
return;
}
this.widgetWindow.showInactive();
}
setupRecordingStateListener(recordingManager: RecordingManager): void {
recordingManager.on("state-changed", (state: RecordingState) => {
const isIdle = state === "idle";
this.updateWidgetVisibility(isIdle).catch((error) => {
logger.main.error("Failed to update widget visibility", error);
});
});
logger.main.info(
"Widget visibility listener connected to recording state changes",
);
}
async syncWidgetVisibility(): Promise<void> {
const recordingManager =
ServiceManager.getInstance()!.getService("recordingManager")!;
const recordingState = recordingManager.getState();
const isIdle = recordingState === "idle";
await this.updateWidgetVisibility(isIdle);
}
private setupDisplayChangeNotifications(): void {

View file

@ -53,38 +53,46 @@ if (app.isPackaged && isWindows()) {
const appManager = new AppManager();
// Store the deep link URL for processing after app is ready
let deeplinkingUrl: string | null = null;
// Track initialization state for deep link handling
let isInitialized = false;
let pendingDeepLink: string | null = null;
// Handle protocol on macOS
app.on("open-url", (event, url) => {
event.preventDefault();
if (app.isReady()) {
if (isInitialized) {
appManager.handleDeepLink(url);
} else {
deeplinkingUrl = url;
pendingDeepLink = url;
}
});
// 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();
if (isInitialized) {
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);
if (isInitialized) {
appManager.handleDeepLink(url);
} else {
pendingDeepLink = url;
}
}
});
app.whenReady().then(() => {
appManager.initialize();
app.whenReady().then(async () => {
await appManager.initialize();
isInitialized = true;
// Process any deep link that was received before app was ready
if (deeplinkingUrl) {
appManager.handleDeepLink(deeplinkingUrl);
deeplinkingUrl = null;
// Process any deep link that was received before initialization completed
if (pendingDeepLink) {
appManager.handleDeepLink(pendingDeepLink);
pendingDeepLink = null;
}
});
app.on("will-quit", () => appManager.cleanup());

View file

@ -1,169 +0,0 @@
import { app } from "electron";
import { logger } from "../logger";
import type { WindowManager } from "../core/window-manager";
import type { OnboardingService } from "../../services/onboarding-service";
import type { OnboardingState } from "../../types/onboarding";
export class OnboardingManager {
private windowManager: WindowManager;
private onboardingService: OnboardingService;
private isOnboardingInProgress = false;
constructor(
windowManager: WindowManager,
onboardingService: OnboardingService,
) {
this.windowManager = windowManager;
this.onboardingService = onboardingService;
}
/**
* Initialize onboarding manager
*/
async initialize(): Promise<void> {
logger.main.info("Initializing OnboardingManager");
// Any initialization logic can go here
}
/**
* Start the onboarding flow
*/
async startOnboarding(): Promise<void> {
if (this.isOnboardingInProgress) {
logger.main.warn("Onboarding already in progress");
return;
}
this.isOnboardingInProgress = true;
logger.main.info("Starting onboarding flow");
// Create and show the onboarding window
await this.windowManager.createOrShowOnboardingWindow();
// Track onboarding started event
this.onboardingService.trackOnboardingStarted(process.platform);
}
/**
* Complete the onboarding process
*/
async completeOnboarding(finalState: OnboardingState): Promise<void> {
try {
logger.main.info("Completing onboarding");
// Save the final state
await this.onboardingService.completeOnboarding(finalState);
this.isOnboardingInProgress = false;
// Close onboarding window
const onboardingWindow = this.windowManager.getOnboardingWindow();
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.close();
}
// Determine if we need to relaunch
const isDevelopment = process.env.NODE_ENV === "development";
if (isDevelopment) {
// In development, reload windows
logger.main.info("Development mode: Reloading windows");
await this.reloadWindows();
} else {
// In production, relaunch the app
logger.main.info("Production mode: Relaunching app");
this.relaunchApp();
}
} catch (error) {
logger.main.error("Error completing onboarding:", error);
throw error;
}
}
/**
* Handle onboarding cancellation
*/
async cancelOnboarding(): Promise<void> {
logger.main.info("Onboarding cancelled");
this.isOnboardingInProgress = false;
// Track abandonment event
const currentState = await this.onboardingService.getOnboardingState();
const lastScreen =
currentState?.lastVisitedScreen ||
currentState?.skippedScreens?.[currentState.skippedScreens.length - 1] ||
"unknown";
this.onboardingService.trackOnboardingAbandoned(lastScreen);
// Close the onboarding window
const onboardingWindow = this.windowManager.getOnboardingWindow();
if (onboardingWindow && !onboardingWindow.isDestroyed()) {
onboardingWindow.close();
}
// Quit the app since onboarding was not completed
app.quit();
}
/**
* Reload windows in development mode
*/
private async reloadWindows(): Promise<void> {
try {
// Create main window
await this.windowManager.createOrShowMainWindow();
// Create widget window if enabled
const settings = await this.onboardingService.getOnboardingState();
if (settings?.featureInterests?.includes("contextual_dictation" as any)) {
await this.windowManager.createWidgetWindow();
}
} catch (error) {
logger.main.error("Error reloading windows:", error);
}
}
/**
* Relaunch the application in production mode
*/
private relaunchApp(): void {
app.relaunch();
app.quit();
}
/**
* Check if onboarding is currently in progress
*/
isInProgress(): boolean {
return this.isOnboardingInProgress;
}
/**
* Get the current onboarding state
*/
async getState(): Promise<OnboardingState | null> {
return this.onboardingService.getOnboardingState();
}
/**
* Update onboarding preferences
*/
async updatePreferences(preferences: any): Promise<void> {
return this.onboardingService.savePreferences(preferences);
}
/**
* Get system model recommendation
*/
async getSystemRecommendation(): Promise<any> {
return this.onboardingService.getSystemRecommendation();
}
/**
* Get feature flags for onboarding
*/
getFeatureFlags(): any {
return this.onboardingService.getFeatureFlags();
}
}

View file

@ -1,14 +1,13 @@
import { ipcMain, app, dialog } from "electron";
import { EventEmitter } from "node:events";
import { logger, logPerformance } from "../logger";
import { ServiceManager } from "./service-manager";
import type { ServiceManager } from "@/main/managers/service-manager";
import type { RecordingState } from "../../types/recording";
import { Mutex } from "async-mutex";
import type { ShortcutManager } from "../services/shortcut-manager";
import type { ShortcutManager } from "./shortcut-manager";
import { StreamingWavWriter } from "../../utils/streaming-wav-writer";
import * as fs from "node:fs";
import * as path from "node:path";
import { appContextStore } from "@/stores/app-context";
export type RecordingMode = "idle" | "ptt" | "hands-free";
@ -164,15 +163,6 @@ export class RecordingManager extends EventEmitter {
const transcriptionService = this.serviceManager.getService(
"transcriptionService",
);
if (!transcriptionService) {
logger.audio.error("Transcription service not available");
// Show error dialog
dialog.showErrorBox(
"Recording Failed",
"Transcription service is not available. Please restart the application.",
);
return;
}
const hasModels = await transcriptionService.isModelAvailable();
const modelCheckDuration = performance.now() - modelCheckStartTime;
@ -214,8 +204,9 @@ export class RecordingManager extends EventEmitter {
const timestamp = new Date().toISOString().replace(/[:.]/g, "-");
this.currentSessionId = `session-${timestamp}`;
// Get accessibility context from global store (async, not awaited)
appContextStore.refreshAccessibilityData();
// Refresh accessibility context from NativeBridge (async, not awaited)
const nativeBridge = this.serviceManager.getService("nativeBridge");
nativeBridge?.refreshAccessibilityContext();
logger.audio.info(
"RecordingManager: Triggered accessibility context refresh (async)",
);
@ -240,7 +231,6 @@ export class RecordingManager extends EventEmitter {
// Mute system audio (async, non-blocking)
const muteStartTime = performance.now();
const nativeBridge = this.serviceManager.getService("nativeBridge");
if (nativeBridge) {
nativeBridge
.call("muteSystemAudio", {})

View file

@ -1,28 +1,24 @@
import { logger } from "../logger";
import { ModelManagerService } from "../../services/model-manager";
import { ModelService } from "../../services/model-service";
import { TranscriptionService } from "../../services/transcription-service";
import { SettingsService } from "../../services/settings-service";
import { NativeBridge } from "../../services/platform/native-bridge-service";
import { AutoUpdaterService } from "../services/auto-updater";
import { RecordingManager } from "./recording-manager";
import { VADService } from "../../services/vad-service";
import { ShortcutManager } from "../services/shortcut-manager";
import { ShortcutManager } from "./shortcut-manager";
import { WindowManager } from "../core/window-manager";
import { createIPCHandler } from "electron-trpc-experimental/main";
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";
import { OnboardingService } from "../../services/onboarding-service";
import { OnboardingManager } from "./onboarding-manager";
/**
* Service map for type-safe service access
*/
export interface ServiceMap {
telemetryService: TelemetryService;
modelManagerService: ModelManagerService;
modelService: ModelService;
transcriptionService: TranscriptionService;
settingsService: SettingsService;
authService: AuthService;
@ -33,7 +29,6 @@ export interface ServiceMap {
shortcutManager: ShortcutManager;
windowManager: WindowManager;
onboardingService: OnboardingService;
onboardingManager: OnboardingManager;
}
/**
@ -44,7 +39,7 @@ export class ServiceManager {
private isInitialized = false;
private telemetryService: TelemetryService | null = null;
private modelManagerService: ModelManagerService | null = null;
private modelService: ModelService | null = null;
private transcriptionService: TranscriptionService | null = null;
private settingsService: SettingsService | null = null;
private authService: AuthService | null = null;
@ -56,8 +51,6 @@ export class ServiceManager {
private recordingManager: RecordingManager | null = null;
private shortcutManager: ShortcutManager | null = null;
private windowManager: WindowManager | null = null;
private onboardingManager: OnboardingManager | null = null;
private trpcHandler: ReturnType<typeof createIPCHandler> | null = null;
async initialize(): Promise<void> {
if (this.isInitialized) {
@ -79,13 +72,12 @@ export class ServiceManager {
this.initializeRecordingManager();
await this.initializeShortcutManager();
this.initializeAutoUpdater();
this.initializeTRPCHandler();
this.isInitialized = true;
logger.main.info("Services initialized successfully");
} catch (error) {
logger.main.error("Failed to initialize services:", error);
// Don't throw here - allow app to start even if some services fail
throw error;
}
}
@ -121,26 +113,13 @@ export class ServiceManager {
logger.main.info("Onboarding service initialized");
}
initializeOnboardingManager(windowManager: WindowManager): void {
if (!this.onboardingService) {
logger.main.warn("Onboarding service not available for manager");
return;
}
this.onboardingManager = new OnboardingManager(
windowManager,
this.onboardingService,
);
logger.main.info("Onboarding manager initialized");
}
private async initializeModelServices(): Promise<void> {
// Initialize Model Manager Service
if (!this.settingsService) {
throw new Error("Settings service not initialized");
}
this.modelManagerService = new ModelManagerService(this.settingsService);
await this.modelManagerService.initialize();
this.modelService = new ModelService(this.settingsService);
await this.modelService.initialize();
}
private async initializeVADService(): Promise<void> {
@ -156,7 +135,7 @@ export class ServiceManager {
private async initializeAIServices(): Promise<void> {
try {
if (!this.modelManagerService) {
if (!this.modelService) {
throw new Error("Model manager service not initialized");
}
@ -165,10 +144,11 @@ export class ServiceManager {
}
this.transcriptionService = new TranscriptionService(
this.modelManagerService,
this.modelService,
this.vadService!,
this.settingsService,
this.telemetryService!,
this.nativeBridge,
);
await this.transcriptionService.initialize();
@ -228,11 +208,6 @@ export class ServiceManager {
// Connect shortcut events to recording manager
this.recordingManager.setupShortcutListeners(this.shortcutManager);
// Connect widget visibility to recording state changes
if (this.windowManager && this.recordingManager) {
this.windowManager.setupRecordingStateListener(this.recordingManager);
}
logger.main.info("Shortcut manager initialized");
}
@ -240,56 +215,33 @@ export class ServiceManager {
this.autoUpdaterService = new AutoUpdaterService();
}
private initializeTRPCHandler(): void {
// Initialize with empty windows array, windows will be added later
this.trpcHandler = createIPCHandler({
router,
windows: [],
createContext: async () => createContext(this),
});
logger.main.info("tRPC handler initialized");
}
getTRPCHandler(): ReturnType<typeof createIPCHandler> | null {
if (!this.isInitialized) {
throw new Error(
"ServiceManager not initialized. Call initialize() first.",
);
}
if (!this.trpcHandler) {
throw new Error("TRPCHandler failed to initialize");
}
return this.trpcHandler;
}
getLogger() {
return logger;
}
getService<K extends keyof ServiceMap>(serviceName: K): ServiceMap[K] | null {
getService<K extends keyof ServiceMap>(serviceName: K): ServiceMap[K] {
if (!this.isInitialized) {
throw new Error(
"ServiceManager not initialized. Call initialize() first.",
);
}
const services: Partial<ServiceMap> = {
telemetryService: this.telemetryService ?? undefined,
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,
recordingManager: this.recordingManager ?? undefined,
shortcutManager: this.shortcutManager ?? undefined,
windowManager: this.windowManager ?? undefined,
onboardingService: this.onboardingService ?? undefined,
onboardingManager: this.onboardingManager ?? undefined,
const services: ServiceMap = {
telemetryService: this.telemetryService!,
modelService: this.modelService!,
transcriptionService: this.transcriptionService!,
settingsService: this.settingsService!,
authService: this.authService!,
vadService: this.vadService!,
nativeBridge: this.nativeBridge!,
autoUpdaterService: this.autoUpdaterService!,
recordingManager: this.recordingManager!,
shortcutManager: this.shortcutManager!,
windowManager: this.windowManager!,
onboardingService: this.onboardingService!,
};
return services[serviceName] ?? null;
return services[serviceName];
}
async cleanup(): Promise<void> {
@ -301,9 +253,9 @@ export class ServiceManager {
logger.main.info("Cleaning up recording manager...");
await this.recordingManager.cleanup();
}
if (this.modelManagerService) {
if (this.modelService) {
logger.main.info("Cleaning up model downloads...");
this.modelManagerService.cleanup();
this.modelService.cleanup();
}
if (this.vadService) {
@ -326,19 +278,11 @@ export class ServiceManager {
return this.onboardingService;
}
getOnboardingManager(): OnboardingManager | null {
return this.onboardingManager;
}
getSettingsService(): SettingsService | null {
return this.settingsService;
}
static getInstance(): ServiceManager | null {
return ServiceManager.instance;
}
static createInstance(): ServiceManager {
static getInstance(): ServiceManager {
if (!ServiceManager.instance) {
ServiceManager.instance = new ServiceManager();
}

View file

@ -3,7 +3,7 @@ import {
TranscribeParams,
} from "../../core/pipeline-types";
import { logger } from "../../../main/logger";
import { ModelManagerService } from "../../../services/model-manager";
import { ModelService } from "../../../services/model-service";
import { SimpleForkWrapper } from "./simple-fork-wrapper";
import * as path from "path";
import { app } from "electron";
@ -11,7 +11,7 @@ import { app } from "electron";
export class WhisperProvider implements TranscriptionProvider {
readonly name = "whisper-local";
private modelManager: ModelManagerService;
private modelService: ModelService;
private workerWrapper: SimpleForkWrapper | null = null;
// Frame aggregation state
@ -48,8 +48,8 @@ export class WhisperProvider implements TranscriptionProvider {
private readonly SPEECH_PROBABILITY_THRESHOLD = 0.2; // Threshold for speech detection
private readonly IGNORE_FULLY_SILENT_CHUNKS = true;
constructor(modelManager: ModelManagerService) {
this.modelManager = modelManager;
constructor(modelService: ModelService) {
this.modelService = modelService;
}
/**
@ -328,7 +328,7 @@ export class WhisperProvider implements TranscriptionProvider {
await this.workerWrapper.initialize();
}
const modelPath = await this.modelManager.getBestAvailableModelPath();
const modelPath = await this.modelService.getBestAvailableModelPath();
if (!modelPath) {
throw new Error(
"No Whisper models available. Please download a model first.",

View file

@ -4,12 +4,13 @@ import { Tabs, TabsList, TabsTrigger, TabsContent } from "@/components/ui/tabs";
import SpeechTab from "./tabs/SpeechTab";
import LanguageTab from "./tabs/LanguageTab";
import EmbeddingTab from "./tabs/EmbeddingTab";
import { useNavigate } from "@tanstack/react-router";
import { Route } from "../../../routes/settings/ai-models";
import { useNavigate, getRouteApi } from "@tanstack/react-router";
const routeApi = getRouteApi("/settings/ai-models");
export default function AIModelsSettingsPage() {
const navigate = useNavigate();
const { tab } = Route.useSearch();
const { tab } = routeApi.useSearch();
return (
<div className="container mx-auto p-6 max-w-5xl">

View file

@ -1,4 +1,4 @@
import React, { useState, useEffect, useCallback } from "react";
import React, { useState, useEffect, useCallback, useRef } from "react";
import { api } from "@/trpc/react";
import { useOnboardingState } from "./hooks/useOnboardingState";
import { ProgressIndicator } from "./components/shared/ProgressIndicator";
@ -49,21 +49,16 @@ export function App() {
const { state, isLoading, savePreferences, completeOnboarding } =
useOnboardingState();
// Ref to hold stable reference to savePreferences (avoids infinite loop in useEffect)
const savePreferencesRef = useRef(savePreferences);
savePreferencesRef.current = savePreferences;
// Ref to ensure initialization only runs once (prevents re-running on dependency changes)
const hasInitialized = useRef(false);
// tRPC queries
const featureFlagsQuery = api.onboarding.getFeatureFlags.useQuery();
const skippedScreensQuery = api.onboarding.getSkippedScreens.useQuery();
// Telemetry mutations
const trackOnboardingStarted =
api.onboarding.trackOnboardingStarted.useMutation();
const trackOnboardingScreenViewed =
api.onboarding.trackOnboardingScreenViewed.useMutation();
const trackOnboardingFeaturesSelected =
api.onboarding.trackOnboardingFeaturesSelected.useMutation();
const trackOnboardingDiscoverySelected =
api.onboarding.trackOnboardingDiscoverySelected.useMutation();
const trackOnboardingModelSelected =
api.onboarding.trackOnboardingModelSelected.useMutation();
const utils = api.useUtils();
// Screen order - can be modified based on feature flags
@ -129,8 +124,15 @@ export function App() {
await checkPermissionsWithResult();
}, [checkPermissionsWithResult]);
// Initialize platform and permissions
// Initialize platform and permissions (runs once when state is ready)
useEffect(() => {
// Wait for state to be ready before initializing
if (isLoading) return;
// Skip if already initialized (prevents re-running when dependencies change)
if (hasInitialized.current) return;
hasInitialized.current = true;
const initialize = async () => {
// Check initial permissions and platform
// Use fresh results directly to avoid race condition
@ -165,48 +167,27 @@ export function App() {
setCurrentScreen(state.lastVisitedScreen as OnboardingScreen);
}
}
// Track onboarding started event (T034)
trackOnboardingStarted.mutate({
platform: platformResult,
resumed: !!state?.lastVisitedScreen,
resumedFrom: state?.lastVisitedScreen,
});
};
initialize();
}, [
isLoading,
checkPermissionsWithResult,
trackOnboardingStarted,
utils,
state?.lastVisitedScreen,
getActiveScreens,
]);
// Save current screen for resume capability
// Save current screen for resume capability (telemetry tracked in backend)
useEffect(() => {
if (currentScreen !== OnboardingScreen.Welcome) {
// Don't save Welcome screen, start from there if no progress
// lastVisitedScreen is not in OnboardingPreferences type, but is saved to state
savePreferences({
// Use ref to avoid dependency on savePreferences which changes identity on mutation state
savePreferencesRef.current({
lastVisitedScreen: currentScreen,
} as Partial<OnboardingPreferences>);
});
}
}, [currentScreen, savePreferences]);
// Track screen views (T035)
useEffect(() => {
trackOnboardingScreenViewed.mutate({
screen: currentScreen,
index: getCurrentScreenIndex(),
total: getTotalScreens(),
});
}, [
currentScreen,
trackOnboardingScreenViewed,
getCurrentScreenIndex,
getTotalScreens,
]);
}, [currentScreen]);
// Navigation functions (T028 - Back navigation)
const navigateBack = useCallback(() => {
@ -229,60 +210,41 @@ export function App() {
}, [currentScreen, getActiveScreens]);
// Save preferences and navigate
const handleSaveAndContinue = async (
const handleSaveAndContinue = (
newPreferences: Partial<OnboardingPreferences>,
) => {
try {
// Merge with existing preferences
const updatedPreferences = { ...preferences, ...newPreferences };
setPreferences(updatedPreferences);
// Merge with existing preferences
const updatedPreferences = { ...preferences, ...newPreferences };
setPreferences(updatedPreferences);
// Save to backend (T030 - handled by hook)
await savePreferences(newPreferences);
// Navigate immediately for responsive UX
navigateNext();
// Navigate to next screen
navigateNext();
} catch (error) {
// Save to backend in background (non-blocking)
// Preferences are already in React state, final completion will persist everything
savePreferences(newPreferences).catch((error) => {
console.error("Failed to save preferences:", error);
// Error is already handled by the hook with toast
}
});
};
// Handle feature interests selection (T036)
const handleFeatureInterests = async (interests: FeatureInterest[]) => {
trackOnboardingFeaturesSelected.mutate({
features: interests,
count: interests.length,
});
await handleSaveAndContinue({ featureInterests: interests });
// Handle feature interests selection (telemetry tracked in backend)
const handleFeatureInterests = (interests: FeatureInterest[]) => {
handleSaveAndContinue({ featureInterests: interests });
};
// Handle discovery source selection (T037)
const handleDiscoverySource = async (
source: DiscoverySource,
details?: string,
) => {
trackOnboardingDiscoverySelected.mutate({
source,
details,
});
// Handle discovery source selection (telemetry tracked in backend)
const handleDiscoverySource = (source: DiscoverySource, details?: string) => {
setDiscoveryDetails(details || "");
await handleSaveAndContinue({ discoverySource: source });
handleSaveAndContinue({ discoverySource: source });
};
// Handle model selection (T038)
const handleModelSelection = async (
// Handle model selection (telemetry tracked in backend)
const handleModelSelection = (
modelType: ModelType,
recommendationFollowed: boolean,
) => {
trackOnboardingModelSelected.mutate({
model_type: modelType,
recommendation_followed: recommendationFollowed,
});
await handleSaveAndContinue({
handleSaveAndContinue({
selectedModelType: modelType,
modelRecommendation: state?.modelRecommendation
? { ...state.modelRecommendation, followed: recommendationFollowed }
@ -372,6 +334,7 @@ export function App() {
return (
<CompletionScreen
onComplete={handleComplete}
onBack={navigateBack}
preferences={preferences}
/>
);

View file

@ -1,5 +1,6 @@
import React from "react";
import { Card } from "@/components/ui/card";
import { Button } from "@/components/ui/button";
import { Separator } from "@/components/ui/separator";
import { OnboardingLayout } from "../shared/OnboardingLayout";
import { NavigationButtons } from "../shared/NavigationButtons";
@ -8,8 +9,11 @@ import { OnboardingShortcutInput } from "../shared/OnboardingShortcutInput";
import { CheckCircle, Settings, Info } from "lucide-react";
import { FeatureInterest, ModelType } from "../../../../types/onboarding";
const DISCORD_URL = "https://amical.ai/community";
interface CompletionScreenProps {
onComplete: () => void;
onBack: () => void;
preferences: {
featureInterests?: FeatureInterest[];
modelType?: ModelType;
@ -21,24 +25,25 @@ interface CompletionScreenProps {
*/
export function CompletionScreen({
onComplete,
onBack,
preferences,
}: CompletionScreenProps) {
return (
<OnboardingLayout title="Setup Complete!">
<OnboardingLayout
title="Setup Complete!"
titleIcon={<CheckCircle className="h-7 w-7 text-green-500" />}
footer={
<NavigationButtons
onComplete={onComplete}
onBack={onBack}
showBack={true}
showNext={false}
showComplete={true}
completeLabel="Start Using Amical"
/>
}
>
<div className="space-y-6">
{/* Success Message */}
<div className="flex flex-col items-center space-y-4 text-center">
<div className="rounded-full bg-green-500/10 p-4">
<CheckCircle className="h-12 w-12 text-green-500" />
</div>
<div className="space-y-2">
<h2 className="text-2xl font-semibold">You're all set!</h2>
<p className="text-muted-foreground">
Your voice transcription assistant is ready to use
</p>
</div>
</div>
{/* Quick Configuration */}
<Card className="p-6">
<h3 className="mb-4 font-medium flex items-center gap-2">
@ -52,10 +57,35 @@ export function CompletionScreen({
</div>
</Card>
{/* Community */}
<Card className="p-6">
<div className="flex items-center gap-4">
<div className="rounded-full bg-[#5865F2]/10 p-3">
<img
src="icons/integrations/discord.svg"
alt="Discord"
className="h-6 w-6"
/>
</div>
<div className="flex-1">
<h3 className="font-medium">Join our Community</h3>
<p className="text-sm text-muted-foreground">
Get help, share feedback, and connect with other users
</p>
</div>
<Button
variant="outline"
onClick={() => window.electronAPI.openExternal(DISCORD_URL)}
>
Join Discord
</Button>
</div>
</Card>
{/* Next Steps */}
<Card className="border-primary/20 bg-primary/5 p-6">
<h3 className="mb-3 font-medium">You're All Set!</h3>
<div className="space-y-2">
<Card className="border-primary/20 bg-primary/5 px-6 gap-2">
<h3 className="font-medium">You're All Set!</h3>
<div>
<div className="flex items-start gap-2">
<span className="text-sm font-medium text-primary"></span>
<p className="text-sm">
@ -86,15 +116,6 @@ export function CompletionScreen({
" Your selected local model is ready to use offline."}
</p>
</div>
{/* Complete Button */}
<NavigationButtons
onComplete={onComplete}
showBack={false}
showNext={false}
showComplete={true}
completeLabel="Start Using Amical"
/>
</div>
</OnboardingLayout>
);

View file

@ -80,6 +80,16 @@ export function DiscoverySourceScreen({
<OnboardingLayout
title="How did you discover Amical?"
subtitle="This helps us understand where our users come from"
footer={
<NavigationButtons
onBack={onBack}
onNext={handleContinue}
disableNext={
!selectedSource ||
(selectedSource === DiscoverySource.Other && !otherDetails.trim())
}
/>
}
>
<div className="space-y-6">
{/* Discovery Sources */}
@ -117,16 +127,6 @@ export function DiscoverySourceScreen({
</p>
</div>
)}
{/* Navigation */}
<NavigationButtons
onBack={onBack}
onNext={handleContinue}
disableNext={
!selectedSource ||
(selectedSource === DiscoverySource.Other && !otherDetails.trim())
}
/>
</div>
</OnboardingLayout>
);

View file

@ -7,7 +7,7 @@ import { NavigationButtons } from "../shared/NavigationButtons";
import { ModelSetupModal } from "./ModelSetupModal";
import { useSystemRecommendation } from "../../hooks/useSystemRecommendation";
import { ModelType } from "../../../../types/onboarding";
import { Cloud, HardDrive, Sparkles, Check } from "lucide-react";
import { Cloud, Laptop, Sparkles, Check, X, Star } from "lucide-react";
import { toast } from "sonner";
interface ModelSelectionScreenProps {
@ -41,34 +41,26 @@ export function ModelSelectionScreen({
{
id: ModelType.Cloud,
title: "Amical Cloud",
subtitle: "Fast cloud transcription",
subtitle: "Fast, more accurate, and free - no setup needed",
description:
"Process audio using Amical's cloud servers for fast and accurate transcription. Your audio is never persisted on our servers.",
pros: ["Fast processing", "High accuracy", "No local resources needed"],
cons: [
"Requires internet",
"Requires Amical account",
"Usage limits may apply",
],
"Ideal if you want the best accuracy or your device can't run local models.\nSecure processing; audio is never stored.",
pros: ["Free", "Fast", "More accurate", "No setup needed"],
cons: ["Needs internet & login"],
icon: Cloud,
iconBg: "bg-blue-500/10",
iconColor: "text-blue-500",
},
{
id: ModelType.Local,
title: "Local Models (Whisper)",
subtitle: "Private and offline",
title: "Local Models",
subtitle: "Private, offline, and free - runs fully on your device.",
description:
"OpenAI's Whisper models running directly on your device. Complete privacy with no data leaving your computer.",
pros: ["Complete privacy", "Works offline", "No account required"],
cons: [
"Requires more RAM/CPU",
"Slower than cloud",
"Initial download required",
],
icon: HardDrive,
iconBg: "bg-green-500/10",
iconColor: "text-green-500",
"Great for privacy-focused users with capable hardware. No login required.",
pros: ["Full privacy", "Works offline"],
cons: ["Uses device resources"],
icon: Laptop,
iconBg: "bg-slate-500/10",
iconColor: "text-slate-500",
},
];
@ -101,8 +93,6 @@ export function ModelSelectionScreen({
onNext(selectedModel, followedRecommendation);
};
const getModelById = (id: ModelType) => models.find((m) => m.id === id);
// Check if any setup is complete
const canContinue = selectedModel && setupComplete[selectedModel];
@ -110,8 +100,16 @@ export function ModelSelectionScreen({
<OnboardingLayout
title="Choose Your AI Model"
subtitle="Select how you want Amical to process your audio"
footer={
<NavigationButtons
onBack={onBack}
onNext={handleContinue}
disableNext={!canContinue}
nextLabel={canContinue ? "Continue" : "Complete setup to continue"}
/>
}
>
<div className="space-y-6">
<div className="space-y-4">
{/* System Recommendation */}
{recommendation && !isLoading && (
<Alert className="border-primary/50 bg-primary/5">
@ -156,9 +154,9 @@ export function ModelSelectionScreen({
<div className="flex items-start justify-between">
<div className="flex items-center gap-3">
<div className={`rounded-lg p-2 ${model.iconBg}`}>
<Icon className={`h-5 w-5 ${model.iconColor}`} />
<Icon className={`h-6 w-6 ${model.iconColor}`} />
</div>
<div>
<div className="flex flex-col gap-0.5">
<div className="flex items-center gap-2">
<h3 className="font-medium">{model.title}</h3>
{isRecommended && (
@ -167,9 +165,7 @@ export function ModelSelectionScreen({
</Badge>
)}
</div>
<p className="text-sm text-muted-foreground">
{model.subtitle}
</p>
<p className="text-sm">{model.subtitle}</p>
</div>
</div>
{isComplete && (
@ -180,7 +176,7 @@ export function ModelSelectionScreen({
</div>
{/* Description */}
<p className="text-sm text-muted-foreground">
<p className="text-sm text-muted-foreground whitespace-pre-line">
{model.description}
</p>
@ -192,7 +188,10 @@ export function ModelSelectionScreen({
</p>
<ul className="space-y-0.5 text-muted-foreground">
{model.pros.map((pro, i) => (
<li key={i}> {pro}</li>
<li key={i} className="flex items-center gap-1.5">
<Check className="h-3.5 w-3.5 text-green-500 shrink-0" />
{pro}
</li>
))}
</ul>
</div>
@ -202,7 +201,10 @@ export function ModelSelectionScreen({
</p>
<ul className="space-y-0.5 text-muted-foreground">
{model.cons.map((con, i) => (
<li key={i}> {con}</li>
<li key={i} className="flex items-center gap-1.5">
<X className="h-3.5 w-3.5 text-red-500 shrink-0" />
{con}
</li>
))}
</ul>
</div>
@ -214,22 +216,33 @@ export function ModelSelectionScreen({
})}
</div>
{/* Navigation */}
<NavigationButtons
onBack={onBack}
onNext={handleContinue}
disableNext={!canContinue}
nextLabel={canContinue ? "Continue" : "Complete setup to continue"}
/>
{/* Settings Note */}
<div className="flex items-start gap-2 rounded-lg bg-muted/50 p-4">
<Star className="h-4 w-4 mt-0.5 text-yellow-500 shrink-0 " />
<p className="text-sm text-muted-foreground">
You can change your model later in Settings nothing is permanent.
</p>
</div>
</div>
{/* Setup Modal */}
{selectedModel && (
<ModelSetupModal
isOpen={showSetupModal}
onClose={() => setShowSetupModal(false)}
onClose={(wasCompleted) => {
setShowSetupModal(false);
// Deselect if setup wasn't completed
if (!wasCompleted && !setupComplete[selectedModel]) {
setSelectedModel(null);
}
}}
modelType={selectedModel}
onSetupComplete={handleSetupComplete}
onContinue={() => {
handleSetupComplete();
const followedRecommendation =
recommendation?.suggested === selectedModel;
onNext(selectedModel, followedRecommendation);
}}
/>
)}
</OnboardingLayout>

View file

@ -5,6 +5,7 @@ import {
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from "@/components/ui/dialog";
import { Button } from "@/components/ui/button";
import { Progress } from "@/components/ui/progress";
@ -15,9 +16,9 @@ import { toast } from "sonner";
interface ModelSetupModalProps {
isOpen: boolean;
onClose: () => void;
onClose: (wasCompleted?: boolean) => void;
modelType: ModelType;
onSetupComplete: () => void;
onContinue: () => void; // Called when setup completes - auto-advances to next step
}
/**
@ -29,7 +30,7 @@ export function ModelSetupModal({
isOpen,
onClose,
modelType,
onSetupComplete,
onContinue,
}: ModelSetupModalProps) {
const [isLoading, setIsLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@ -41,6 +42,7 @@ export function ModelSetupModal({
} | null>(null);
const [modelAlreadyInstalled, setModelAlreadyInstalled] = useState(false);
const [installedModelName, setInstalledModelName] = useState<string>("");
const [downloadComplete, setDownloadComplete] = useState(false);
// tRPC mutations and utils
const utils = api.useUtils();
@ -50,8 +52,7 @@ export function ModelSetupModal({
const authStatus = await utils.auth.getAuthStatus.fetch();
if (authStatus.isAuthenticated) {
toast.success("Successfully authenticated!");
onSetupComplete();
onClose();
onContinue();
} else {
setError("Authentication failed. Please try again.");
}
@ -85,8 +86,7 @@ export function ModelSetupModal({
});
if (data.progress.progress === 100) {
onSetupComplete();
onClose();
setDownloadComplete(true);
}
}
},
@ -129,14 +129,9 @@ export function ModelSetupModal({
);
if (whisperModels.length > 0) {
// Model already exists, mark as complete
// Model already exists - user must click Done to confirm
setModelAlreadyInstalled(true);
setInstalledModelName(whisperModels[0].name || whisperModels[0].id);
onSetupComplete();
// Don't close immediately to show the success state
setTimeout(() => {
onClose();
}, 2000);
} else if (!isLoading && !downloadProgress) {
// No existing model, start download
startDownload();
@ -155,47 +150,21 @@ export function ModelSetupModal({
return (
<>
<DialogHeader>
<DialogTitle>Sign in to Amical</DialogTitle>
<DialogTitle>Sign in required</DialogTitle>
<DialogDescription>
Sign in with your Amical account to use cloud transcription
Cloud transcription needs authentication to continue.
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{error && (
<div className="flex items-center gap-2 rounded-lg bg-destructive/10 p-3 text-sm text-destructive">
<AlertCircle className="h-4 w-4" />
{error}
</div>
)}
<Button
onClick={handleAmicalLogin}
disabled={isLoading}
className="w-full"
size="lg"
>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign in to Amical
<DialogFooter className="space-x-2">
<Button variant="outline" onClick={() => onClose(false)}>
Cancel
</Button>
<p className="text-center text-sm text-muted-foreground">
Don't have an account?{" "}
<button
onClick={handleAmicalLogin}
className="text-primary hover:underline"
>
Create one
</button>
</p>
<div className="rounded-lg bg-muted/50 p-3">
<p className="text-xs text-muted-foreground text-center">
Your audio is processed in real-time and never stored on our
servers.
</p>
</div>
</div>
<Button onClick={handleAmicalLogin} disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" />}
Sign In
</Button>
</DialogFooter>
</>
);
}
@ -205,28 +174,32 @@ export function ModelSetupModal({
<>
<DialogHeader>
<DialogTitle>
{modelAlreadyInstalled
{modelAlreadyInstalled || downloadComplete
? "Local Model Ready"
: "Downloading Local Model"}
</DialogTitle>
<DialogDescription>
{modelAlreadyInstalled
? "Your system already has a Whisper model installed"
{modelAlreadyInstalled || downloadComplete
? "Ready for private, offline transcription."
: "Setting up Whisper Tiny for private, offline transcription"}
</DialogDescription>
</DialogHeader>
<div className="space-y-4 py-4">
{modelAlreadyInstalled ? (
// Show success state when model is already installed
<div className="flex flex-col items-center gap-3 py-4">
{modelAlreadyInstalled || downloadComplete ? (
// Show success state when model is ready
<div className="flex flex-col items-center gap-3">
<div className="rounded-full bg-green-500/10 p-3">
<Check className="h-6 w-6 text-green-500" />
</div>
<div className="text-center">
<p className="font-medium">Model Already Installed</p>
<p className="font-medium">
{modelAlreadyInstalled
? "Model Already Installed"
: "Download Complete"}
</p>
<p className="text-sm text-muted-foreground mt-1">
Using: {installedModelName}
Using: {installedModelName || "whisper-tiny"}
</p>
</div>
</div>
@ -265,21 +238,27 @@ export function ModelSetupModal({
)}
</div>
)}
{downloadProgress < 100 && (
<Button onClick={onClose} variant="outline" className="w-full">
Cancel Download
</Button>
)}
</>
)}
</div>
<DialogFooter className="space-x-2">
<Button variant="outline" onClick={() => onClose(false)}>
Cancel
</Button>
<Button
onClick={onContinue}
disabled={!modelAlreadyInstalled && !downloadComplete}
>
Continue
</Button>
</DialogFooter>
</>
);
};
return (
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose(false)}>
<DialogContent className="sm:max-w-md">{renderContent()}</DialogContent>
</Dialog>
);

View file

@ -134,6 +134,16 @@ export function PermissionsScreen({
<OnboardingLayout
title="Setup Permissions"
subtitle="Amical needs a few permissions to work properly"
footer={
<NavigationButtons
onBack={onBack}
onNext={onNext}
disableNext={!allPermissionsGranted}
nextLabel={
allPermissionsGranted ? "Continue" : "Waiting for permissions..."
}
/>
}
>
<div className="space-y-6">
{/* Status Summary */}
@ -250,7 +260,8 @@ export function PermissionsScreen({
<div>
<h3 className="font-medium">Accessibility Access</h3>
<p className="mt-1 text-sm text-muted-foreground">
Required for global keyboard shortcuts (macOS only)
Required for pasting transcription and global keyboard
shortcuts (macOS only)
</p>
{permissions.accessibility ? (
@ -291,16 +302,6 @@ export function PermissionsScreen({
</Card>
)}
</div>
{/* Navigation */}
<NavigationButtons
onBack={onBack}
onNext={onNext}
disableNext={!allPermissionsGranted}
nextLabel={
allPermissionsGranted ? "Continue" : "Waiting for permissions..."
}
/>
</div>
</OnboardingLayout>
);

View file

@ -4,7 +4,7 @@ import { Checkbox } from "@/components/ui/checkbox";
import { Badge } from "@/components/ui/badge";
import { OnboardingLayout } from "../shared/OnboardingLayout";
import { NavigationButtons } from "../shared/NavigationButtons";
import { Mic, FileText, Users } from "lucide-react";
import { Mic, FileText, Users, Command } from "lucide-react";
import { FeatureInterest } from "../../../../types/onboarding";
import { toast } from "sonner";
@ -49,6 +49,13 @@ export function WelcomeScreen({
"Record and transcribe meetings and conversations with high accuracy",
icon: Users,
},
{
id: FeatureInterest.VoiceCommands,
title: "Voice Commands",
description:
"Control your apps hands-free - act on tasks with natural voice commands",
icon: Command,
},
];
const handleToggleInterest = (interest: FeatureInterest) => {
@ -56,9 +63,9 @@ export function WelcomeScreen({
if (newInterests.has(interest)) {
newInterests.delete(interest);
} else {
// Maximum 3 interests
if (newInterests.size >= 3) {
toast.error("You can select up to 3 features");
// Maximum 4 interests
if (newInterests.size >= 4) {
toast.error("You can select up to 4 features");
return;
}
newInterests.add(interest);
@ -80,6 +87,25 @@ export function WelcomeScreen({
<OnboardingLayout
title="Welcome to Amical"
subtitle="Select the features you're interested in to personalize your experience"
footer={
<div className="space-y-4">
<NavigationButtons
onNext={handleContinue}
showBack={false}
disableNext={selectedInterests.size === 0}
/>
{onSkip && (
<div className="text-center">
<button
onClick={onSkip}
className="text-sm text-muted-foreground hover:text-foreground"
>
Skip onboarding
</button>
</div>
)}
</div>
}
>
<div className="space-y-4">
{/* Feature Selection Cards */}
@ -121,8 +147,9 @@ export function WelcomeScreen({
<div className="flex-1">
<div className="flex items-center gap-1.5 flex-wrap">
<h3 className="font-medium">{feature.title}</h3>
{feature.id ===
FeatureInterest.MeetingTranscriptions && (
{(feature.id ===
FeatureInterest.MeetingTranscriptions ||
feature.id === FeatureInterest.VoiceCommands) && (
<Badge
variant="outline"
className="text-[10px] px-1.5 py-0 h-4 shrink-0"
@ -154,30 +181,10 @@ export function WelcomeScreen({
{/* Settings Note */}
<div className="rounded-lg bg-muted/50 p-4">
<p className="text-sm text-muted-foreground">
<span className="font-medium">Flexible preferences:</span> Don't
worry about getting it perfect you can change all preferences
anytime in Settings.
Your choices help personalize setup all features remain available
anytime.
</p>
</div>
{/* Navigation */}
<NavigationButtons
onNext={handleContinue}
showBack={false}
disableNext={selectedInterests.size === 0}
/>
{/* Skip Option */}
{onSkip && (
<div className="text-center">
<button
onClick={onSkip}
className="text-sm text-muted-foreground hover:text-foreground"
>
Skip onboarding
</button>
</div>
)}
</div>
</OnboardingLayout>
);

View file

@ -37,7 +37,7 @@ export function NavigationButtons({
return (
<div
className={cn(
"flex items-center justify-between pt-6",
"flex items-center justify-between pt-4",
!showBack && "justify-end",
className,
)}

View file

@ -3,7 +3,9 @@ import { cn } from "@/lib/utils";
interface OnboardingLayoutProps {
children: React.ReactNode;
footer?: React.ReactNode;
title?: string;
titleIcon?: React.ReactNode;
subtitle?: string;
className?: string;
}
@ -14,23 +16,27 @@ interface OnboardingLayoutProps {
*/
export function OnboardingLayout({
children,
footer,
title,
titleIcon,
subtitle,
className,
}: OnboardingLayoutProps) {
return (
<div
className={cn(
"flex flex-col items-center bg-background py-4 px-6",
"flex h-full flex-col items-center bg-background px-6 py-4",
className,
)}
>
<div className="w-full max-w-3xl">
{/* Scrollable content area */}
<div className="flex-1 w-full max-w-3xl overflow-auto">
{/* Header */}
{(title || subtitle) && (
<div className="mb-4 text-center">
{title && (
<h1 className="text-2xl font-bold tracking-tight text-foreground">
<h1 className="flex items-center justify-center gap-2 text-2xl font-bold tracking-tight text-foreground">
{titleIcon}
{title}
</h1>
)}
@ -45,6 +51,9 @@ export function OnboardingLayout({
{children}
</div>
</div>
{/* Footer - pinned to bottom */}
{footer && <div className="w-full max-w-3xl pt-4 mt-auto">{footer}</div>}
</div>
);
}

View file

@ -1,18 +1,15 @@
import React, { useState, useEffect } from "react";
import { Button } from "@/components/ui/button";
import { useState, useEffect } from "react";
import { Label } from "@/components/ui/label";
import { X, Pencil } from "lucide-react";
import { ShortcutInput } from "@/components/shortcut-input";
import { api } from "@/trpc/react";
const MODIFIER_KEYS = ["Cmd", "Win", "Ctrl", "Alt", "Shift", "Fn"];
/**
* Simplified shortcut input component for onboarding - Push to Talk only
* Push to Talk shortcut input for onboarding
* Wraps ShortcutInput with label and handles data fetching/saving
*/
export function OnboardingShortcutInput() {
const [pushToTalkShortcut, setPushToTalkShortcut] = useState("");
const [isRecording, setIsRecording] = useState(false);
const [activeKeys, setActiveKeys] = useState<string[]>([]);
const utils = api.useUtils();
const shortcutsQuery = api.settings.getShortcuts.useQuery();
@ -21,8 +18,6 @@ export function OnboardingShortcutInput() {
utils.settings.getShortcuts.invalidate();
},
});
const setRecordingStateMutation =
api.settings.setShortcutRecordingState.useMutation();
// Load current shortcut
useEffect(() => {
@ -31,47 +26,14 @@ export function OnboardingShortcutInput() {
}
}, [shortcutsQuery.data]);
const handleStartRecording = () => {
setIsRecording(true);
setActiveKeys([]);
setRecordingStateMutation.mutate(true);
const handleShortcutChange = (shortcut: string) => {
setPushToTalkShortcut(shortcut);
setShortcutMutation.mutate({
type: "pushToTalk",
shortcut: shortcut,
});
};
const handleCancelRecording = () => {
setIsRecording(false);
setActiveKeys([]);
setRecordingStateMutation.mutate(false);
};
// Subscribe to key events when recording
api.settings.activeKeysUpdates.useSubscription(undefined, {
enabled: isRecording,
onData: (keys: string[]) => {
const previousKeys = activeKeys;
setActiveKeys(keys);
// When any key is released, validate and save
if (previousKeys.length > 0 && keys.length < previousKeys.length) {
// Check if it has at least one modifier key
const hasModifier = previousKeys.some((key) =>
MODIFIER_KEYS.includes(key),
);
if (hasModifier) {
const shortcut = previousKeys.join("+");
setPushToTalkShortcut(shortcut);
setShortcutMutation.mutate({
type: "pushToTalk",
shortcut: shortcut,
});
}
setIsRecording(false);
setRecordingStateMutation.mutate(false);
}
},
});
return (
<div className="flex items-center justify-between">
<div>
@ -83,64 +45,12 @@ export function OnboardingShortcutInput() {
</p>
</div>
<div className="min-w-[200px] flex justify-end">
{isRecording ? (
<div className="inline-flex items-center gap-2 px-3 py-1.5 bg-muted rounded-md ring-2 ring-primary w-full">
{activeKeys.length > 0 ? (
<div className="flex items-center gap-1">
{activeKeys.map((key, index) => (
<kbd
key={index}
className="px-1.5 py-0.5 text-xs bg-background rounded border"
>
{key}
</kbd>
))}
</div>
) : (
<span className="text-sm text-muted-foreground">
Press keys...
</span>
)}
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0 ml-auto"
onClick={handleCancelRecording}
>
<X className="h-3 w-3" />
</Button>
</div>
) : (
<div className="inline-flex items-center gap-2">
{pushToTalkShortcut ? (
<>
<kbd
onClick={handleStartRecording}
className="inline-flex items-center px-3 py-1 bg-muted hover:bg-muted/70 rounded-md text-sm font-mono cursor-pointer transition-colors"
>
{pushToTalkShortcut}
</kbd>
<Button
variant="ghost"
size="sm"
className="h-6 w-6 p-0"
onClick={handleStartRecording}
>
<Pencil className="h-3 w-3" />
</Button>
</>
) : (
<Button
variant="outline"
size="sm"
onClick={handleStartRecording}
className="text-xs"
>
Set shortcut
</Button>
)}
</div>
)}
<ShortcutInput
value={pushToTalkShortcut}
onChange={handleShortcutChange}
isRecordingShortcut={isRecording}
onRecordingShortcutChange={setIsRecording}
/>
</div>
</div>
);

View file

@ -3,10 +3,6 @@ import { api } from "@/trpc/react";
import type {
OnboardingState,
OnboardingPreferences,
OnboardingScreen,
FeatureInterest,
DiscoverySource,
ModelType,
} from "../../../types/onboarding";
import { toast } from "sonner";
@ -31,8 +27,6 @@ export function useOnboardingState(): UseOnboardingStateReturn {
const getStateQuery = api.onboarding.getState.useQuery();
const savePreferencesMutation = api.onboarding.savePreferences.useMutation();
const completeMutation = api.onboarding.complete.useMutation();
const trackOnboardingCompleted =
api.onboarding.trackOnboardingCompleted.useMutation();
const resetMutation = api.onboarding.reset.useMutation();
// Load initial state
@ -100,33 +94,15 @@ export function useOnboardingState(): UseOnboardingStateReturn {
throw new Error("Failed to complete onboarding");
}
// Track completion event
trackOnboardingCompleted.mutate({
version: finalState.completedVersion,
features_selected: finalState.featureInterests || [],
discovery_source: finalState.discoverySource,
model_type: finalState.selectedModelType,
recommendation_followed:
finalState.modelRecommendation?.followed || false,
skipped_screens: finalState.skippedScreens,
});
// Handle relaunch if needed
if (result.shouldRelaunch) {
toast.success("Onboarding complete! Restarting application...");
// The app will relaunch automatically from the main process
} else {
toast.success("Onboarding complete!");
// In development, just reload
window.location.reload();
}
// Main process handles window closing and app relaunch
toast.success("Onboarding complete!");
} catch (err) {
console.error("Failed to complete onboarding:", err);
toast.error("Failed to complete onboarding. Please try again.");
throw err;
}
},
[completeMutation, trackOnboardingCompleted],
[completeMutation],
);
// Reset onboarding (for testing)

View file

@ -1,4 +1,3 @@
import { useEffect, useState } from "react";
import { api } from "@/trpc/react";
import type { ModelRecommendation } from "../../../types/onboarding";
@ -13,19 +12,10 @@ interface UseSystemRecommendationReturn {
* Analyzes system specs and provides intelligent recommendations
*/
export function useSystemRecommendation(): UseSystemRecommendationReturn {
const [recommendation, setRecommendation] =
useState<ModelRecommendation | null>(null);
const query = api.onboarding.getSystemRecommendation.useQuery();
useEffect(() => {
if (query.data) {
setRecommendation(query.data);
}
}, [query.data]);
return {
recommendation,
recommendation: query.data ?? null,
isLoading: query.isLoading,
error: query.error as Error | null,
};

View file

@ -1,101 +0,0 @@
import { FormatterConfig } from "../../types/formatter";
import {
getSettingsSection,
updateSettingsSection,
getAppSettings,
updateAppSettings,
} from "../../db/app-settings";
import type { AppSettingsData } from "../../db/schema";
/**
* Database-backed settings service with typed configuration
*/
export class SettingsService {
private static instance: SettingsService;
private constructor() {}
static getInstance(): SettingsService {
if (!SettingsService.instance) {
SettingsService.instance = new SettingsService();
}
return SettingsService.instance;
}
/**
* Get formatter configuration
*/
async getFormatterConfig(): Promise<FormatterConfig | null> {
const formatterConfig = await getSettingsSection("formatterConfig");
return formatterConfig || null;
}
/**
* Set formatter configuration
*/
async setFormatterConfig(config: FormatterConfig): Promise<void> {
await updateSettingsSection("formatterConfig", config);
}
/**
* Get all app settings
*/
async getAllSettings(): Promise<AppSettingsData> {
return await getAppSettings();
}
/**
* Update multiple settings at once
*/
async updateSettings(
settings: Partial<AppSettingsData>,
): Promise<AppSettingsData> {
return await updateAppSettings(settings);
}
/**
* Get UI settings
*/
async getUISettings(): Promise<AppSettingsData["ui"]> {
return await getSettingsSection("ui");
}
/**
* Update UI settings
*/
async setUISettings(uiSettings: AppSettingsData["ui"]): Promise<void> {
await updateSettingsSection("ui", uiSettings);
}
/**
* Get transcription settings
*/
async getTranscriptionSettings(): Promise<AppSettingsData["transcription"]> {
return await getSettingsSection("transcription");
}
/**
* Update transcription settings
*/
async setTranscriptionSettings(
transcriptionSettings: AppSettingsData["transcription"],
): Promise<void> {
await updateSettingsSection("transcription", transcriptionSettings);
}
/**
* Get recording settings
*/
async getRecordingSettings(): Promise<AppSettingsData["recording"]> {
return await getSettingsSection("recording");
}
/**
* Update recording settings
*/
async setRecordingSettings(
recordingSettings: AppSettingsData["recording"],
): Promise<void> {
await updateSettingsSection("recording", recordingSettings);
}
}

View file

@ -56,7 +56,7 @@ interface ModelManagerEvents {
) => void;
}
class ModelManagerService extends EventEmitter {
class ModelService extends EventEmitter {
private state: ModelManagerState;
private modelsDirectory: string;
private settingsService: SettingsService;
@ -1149,4 +1149,4 @@ class ModelManagerService extends EventEmitter {
}
}
export { ModelManagerService };
export { ModelService };

View file

@ -12,6 +12,7 @@ import {
replaceYjsUpdates,
} from "../db/notes";
import * as Y from "yjs";
import { ipcMain } from "electron";
import { logger } from "../main/logger";
export interface NoteCreateOptions {
@ -31,10 +32,43 @@ class NotesService {
private compactionTask: cron.ScheduledTask | null = null;
private constructor() {
// Set up cron job for daily compaction
this.setupIPCHandlers();
this.setupCompactionCron();
}
private setupIPCHandlers(): void {
ipcMain.handle(
"notes:saveYjsUpdate",
async (_event, noteId: number, update: ArrayBuffer) => {
try {
const updateArray = new Uint8Array(update);
await this.saveYjsUpdate(noteId, updateArray);
logger.main.debug("Saved yjs update", {
noteId,
updateSize: updateArray.length,
});
} catch (error) {
logger.main.error("Failed to save yjs update", error);
throw error;
}
},
);
ipcMain.handle("notes:loadYjsUpdates", async (_event, noteId: number) => {
try {
const updates = await this.loadYjsUpdates(noteId);
logger.main.debug("Loaded yjs updates", {
noteId,
count: updates.length,
});
return updates.map((u) => u.buffer);
} catch (error) {
logger.main.error("Failed to load yjs updates", error);
throw error;
}
});
}
public static getInstance(): NotesService {
if (!NotesService.instance) {
NotesService.instance = new NotesService();

View file

@ -1,3 +1,4 @@
import { EventEmitter } from "events";
import { systemPreferences } from "electron";
import { logger } from "../main/logger";
import type { SettingsService } from "./settings-service";
@ -5,13 +6,13 @@ import type { TelemetryService } from "./telemetry-service";
import type { AppSettingsData } from "../db/schema";
import {
OnboardingScreen,
FeatureInterest,
type OnboardingState,
type OnboardingPreferences,
type ModelRecommendation,
type ModelType,
type OnboardingFeatureFlags,
type SystemSpecs,
type FeatureInterest,
type DiscoverySource,
} from "../types/onboarding";
@ -35,16 +36,18 @@ type OnboardingStateDb = {
};
};
export class OnboardingService {
export class OnboardingService extends EventEmitter {
private static instance: OnboardingService | null = null;
private settingsService: SettingsService;
private telemetryService: TelemetryService;
private currentState: Partial<OnboardingState> = {};
private isOnboardingInProgress = false;
constructor(
settingsService: SettingsService,
telemetryService: TelemetryService,
) {
super();
this.settingsService = settingsService;
this.telemetryService = telemetryService;
}
@ -175,27 +178,56 @@ export class OnboardingService {
/**
* Save user preferences during onboarding
* T030, T031 - Implements savePreferences with partial progress saving
* Also tracks telemetry for each preference type
*/
async savePreferences(preferences: OnboardingPreferences): Promise<void> {
try {
const updates: Partial<OnboardingState> = {};
// Track screen view when lastVisitedScreen changes
if (preferences.lastVisitedScreen !== undefined) {
updates.lastVisitedScreen = preferences.lastVisitedScreen;
this.telemetryService.trackOnboardingScreenViewed({
screen: preferences.lastVisitedScreen,
index: 0, // Index not available here, but screen name is sufficient
total: 5,
});
}
// Track feature interests selection
if (preferences.featureInterests !== undefined) {
updates.featureInterests = preferences.featureInterests;
this.telemetryService.trackOnboardingFeaturesSelected({
features: preferences.featureInterests,
count: preferences.featureInterests.length,
});
}
// Track discovery source selection
if (preferences.discoverySource !== undefined) {
updates.discoverySource = preferences.discoverySource;
this.telemetryService.trackOnboardingDiscoverySelected({
source: preferences.discoverySource,
});
}
// Track model selection
if (preferences.selectedModelType !== undefined) {
updates.selectedModelType = preferences.selectedModelType;
this.telemetryService.trackOnboardingModelSelected({
model_type: preferences.selectedModelType,
recommendation_followed:
preferences.modelRecommendation?.followed ?? false,
});
}
if (preferences.modelRecommendation !== undefined) {
updates.modelRecommendation = preferences.modelRecommendation;
}
// T032 - Save partial progress after each screen
await this.savePartialProgress(updates);
logger.main.info("Saved onboarding preferences:", preferences);
logger.main.debug("Saved onboarding preferences:", preferences);
} catch (error) {
logger.main.error("Failed to save preferences:", error);
throw error;
@ -229,32 +261,6 @@ export class OnboardingService {
}
}
/**
* Read onboarding progress from database
* T033 - Database read method for onboarding state
*/
async readOnboardingProgress(): Promise<OnboardingState | null> {
try {
return await this.getOnboardingState();
} catch (error) {
logger.main.error("Failed to read onboarding progress:", error);
return null;
}
}
/**
* Write onboarding progress to database
* T033 - Database write method for onboarding state
*/
async writeOnboardingProgress(state: OnboardingState): Promise<void> {
try {
await this.saveOnboardingState(state);
} catch (error) {
logger.main.error("Failed to write onboarding progress:", error);
throw error;
}
}
/**
* Complete the onboarding process
*/
@ -439,14 +445,6 @@ export class OnboardingService {
*/
async getSystemRecommendation(): Promise<ModelRecommendation> {
try {
// Check for mock system specs (for testing)
if (process.env.MOCK_SYSTEM_SPECS) {
const mockSpecs = JSON.parse(
process.env.MOCK_SYSTEM_SPECS,
) as SystemSpecs;
return this.calculateModelRecommendation(mockSpecs);
}
// Get real system info from telemetry service
const systemInfo = this.telemetryService.getSystemInfo();
if (!systemInfo) {
@ -543,4 +541,78 @@ export class OnboardingService {
throw error;
}
}
// ============================================
// Flow methods (event-driven architecture)
// ============================================
/**
* Check if onboarding is currently in progress
*/
isInProgress(): boolean {
return this.isOnboardingInProgress;
}
/**
* Start the onboarding flow
* Note: Window creation is handled by AppManager
*/
async startOnboardingFlow(): Promise<void> {
if (this.isOnboardingInProgress) {
logger.main.warn("Onboarding already in progress");
return;
}
this.isOnboardingInProgress = true;
logger.main.info("Starting onboarding flow");
// Track onboarding started event
this.trackOnboardingStarted(process.platform);
}
/**
* Complete the onboarding flow
* Emits "completed" event - AppManager handles window transitions
*/
async completeOnboardingFlow(finalState: OnboardingState): Promise<void> {
try {
logger.main.info("Completing onboarding flow");
// Save the final state
await this.completeOnboarding(finalState);
this.isOnboardingInProgress = false;
// Emit event - AppManager listens and handles window transitions
this.emit("completed");
logger.main.info("Onboarding completed, emitted event");
} catch (error) {
logger.main.error("Error completing onboarding flow:", error);
throw error;
}
}
/**
* Cancel the onboarding flow
* Emits "cancelled" event - AppManager handles window close and app quit
*/
async cancelOnboardingFlow(): Promise<void> {
logger.main.info("Onboarding cancelled");
this.isOnboardingInProgress = false;
// Track abandonment event
const currentState = await this.getOnboardingState();
const lastScreen =
currentState?.lastVisitedScreen ||
currentState?.skippedScreens?.[currentState.skippedScreens.length - 1] ||
"unknown";
this.trackOnboardingAbandoned(lastScreen);
// Emit event - AppManager listens and handles window close + app quit
this.emit("cancelled");
logger.main.info("Onboarding cancelled, emitted event");
}
}

View file

@ -69,6 +69,7 @@ export class NativeBridge extends EventEmitter {
>();
private helperPath: string;
private logger = createScopedLogger("native-bridge");
private accessibilityContext: GetAccessibilityContextResult | null = null;
constructor() {
super();
@ -350,6 +351,36 @@ export class NativeBridge extends EventEmitter {
}
}
/**
* Refresh the cached accessibility context from the native helper.
* This is called asynchronously when recording starts.
*/
async refreshAccessibilityContext(): Promise<void> {
try {
const context = await this.call("getAccessibilityContext", {
editableOnly: false,
});
this.accessibilityContext = context;
this.logger.debug("Accessibility context refreshed", {
hasApplication: !!context.context?.application?.name,
hasFocusedElement: !!context.context?.focusedElement?.role,
hasTextSelection: !!context.context?.textSelection?.selectedText,
hasWindow: !!context.context?.windowInfo?.title,
});
} catch (error) {
this.logger.error("Failed to refresh accessibility context", {
error: error instanceof Error ? error.message : String(error),
});
}
}
/**
* Get the cached accessibility context.
*/
getAccessibilityContext(): GetAccessibilityContextResult | null {
return this.accessibilityContext;
}
// Typed event emitter methods
on<E extends keyof NativeBridgeEvents>(
event: E,

View file

@ -1,4 +1,5 @@
import { app } from "electron";
import { EventEmitter } from "events";
import { FormatterConfig } from "../types/formatter";
import {
getSettingsSection,
@ -23,8 +24,10 @@ export interface AppPreferences {
showWidgetWhileInactive: boolean;
}
export class SettingsService {
constructor() {}
export class SettingsService extends EventEmitter {
constructor() {
super();
}
/**
* Get formatter configuration
@ -73,6 +76,11 @@ export class SettingsService {
*/
async setUISettings(uiSettings: AppSettingsData["ui"]): Promise<void> {
await updateSettingsSection("ui", uiSettings);
// Emit event if theme changed (AppManager will handle window updates)
if (uiSettings?.theme !== undefined) {
this.emit("theme-changed", { theme: uiSettings.theme });
}
}
/**
@ -301,6 +309,13 @@ export class SettingsService {
) {
this.syncAutoLaunch();
}
// Emit event for listeners (AppManager will handle window updates)
this.emit("preferences-changed", {
changes: preferences,
showWidgetWhileInactiveChanged:
preferences.showWidgetWhileInactive !== undefined,
});
}
/**

View file

@ -8,10 +8,10 @@ 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 { ModelService } from "../services/model-service";
import { SettingsService } from "../services/settings-service";
import { appContextStore } from "../stores/app-context";
import { TelemetryService } from "../services/telemetry-service";
import type { NativeBridge } from "./platform/native-bridge-service";
import { createTranscription } from "../db/transcriptions";
import { logger } from "../main/logger";
import { v4 as uuid } from "uuid";
@ -35,30 +35,31 @@ export class TranscriptionService {
private vadMutex: Mutex;
private transcriptionMutex: Mutex;
private telemetryService: TelemetryService;
private modelManagerService: ModelManagerService;
private modelService: ModelService;
private modelWasPreloaded: boolean = false;
constructor(
modelManagerService: ModelManagerService,
modelService: ModelService,
vadService: VADService,
settingsService: SettingsService,
telemetryService: TelemetryService,
private nativeBridge: NativeBridge | null,
) {
this.whisperProvider = new WhisperProvider(modelManagerService);
this.whisperProvider = new WhisperProvider(modelService);
this.cloudProvider = new AmicalCloudProvider();
this.vadService = vadService;
this.settingsService = settingsService;
this.vadMutex = new Mutex();
this.transcriptionMutex = new Mutex();
this.telemetryService = telemetryService;
this.modelManagerService = modelManagerService;
this.modelService = modelService;
}
/**
* Select the appropriate transcription provider based on the selected model
*/
private async selectProvider(): Promise<TranscriptionProvider> {
const selectedModelId = await this.modelManagerService.getSelectedModel();
const selectedModelId = await this.modelService.getSelectedModel();
if (!selectedModelId) {
// Default to whisper if no model selected
@ -81,14 +82,8 @@ export class TranscriptionService {
}
async initialize(): Promise<void> {
if (this.vadService) {
logger.transcription.info("Using VAD service");
} else {
logger.transcription.warn("VAD service not available");
}
// Check if the selected model is a cloud model
const selectedModelId = await this.modelManagerService.getSelectedModel();
const selectedModelId = await this.modelService.getSelectedModel();
const model = selectedModelId
? AVAILABLE_MODELS.find((m) => m.id === selectedModelId)
: null;
@ -158,8 +153,8 @@ export class TranscriptionService {
*/
public async isModelAvailable(): Promise<boolean> {
try {
const modelManager = this.whisperProvider["modelManager"];
const availableModels = await modelManager.getValidDownloadedModels();
const modelService = this.whisperProvider["modelService"];
const availableModels = await modelService.getValidDownloadedModels();
return Object.keys(availableModels).length > 0;
} catch (error) {
logger.transcription.error("Failed to check model availability:", error);
@ -288,9 +283,9 @@ export class TranscriptionService {
accumulatedTranscription: [],
};
// Get accessibility context from global store
// Get accessibility context from NativeBridge
streamingContext.sharedData.accessibilityContext =
appContextStore.getAccessibilityContext();
this.nativeBridge?.getAccessibilityContext() ?? null;
session = {
context: streamingContext,
@ -464,7 +459,7 @@ export class TranscriptionService {
? completionTime - session.recordingStartedAt
: undefined;
const selectedModel = await this.modelManagerService.getSelectedModel();
const selectedModel = await this.modelService.getSelectedModel();
const audioDurationSeconds =
session.context.sharedData.audioMetadata?.duration;

View file

@ -1,41 +0,0 @@
import { GetAccessibilityContextResult } from "@amical/types";
import { ServiceManager } from "../main/managers/service-manager";
import { logger } from "../main/logger";
class AppContextStore {
private accessibilityContext: GetAccessibilityContextResult | null = null;
async refreshAccessibilityData(): Promise<void> {
try {
const serviceManager = ServiceManager.getInstance();
if (!serviceManager) return; // Silent fail
const nativeBridge = serviceManager.getService("nativeBridge");
if (!nativeBridge) {
logger.main.warn("Native bridge not available");
return;
}
const context = await nativeBridge.call("getAccessibilityContext", {
editableOnly: false,
});
this.accessibilityContext = context;
logger.main.debug("Accessibility context refreshed", {
hasApplication: !!context.context?.application?.name,
hasFocusedElement: !!context.context?.focusedElement?.role,
hasTextSelection: !!context.context?.textSelection?.selectedText,
hasWindow: !!context.context?.windowInfo?.title,
});
} catch (error) {
logger.main.error("Failed to refresh accessibility context", {
error: error instanceof Error ? error.message : String(error),
});
}
}
getAccessibilityContext(): GetAccessibilityContextResult | null {
return this.accessibilityContext;
}
}
export const appContextStore = new AppContextStore();

View file

@ -6,7 +6,7 @@ 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 authService = ctx.serviceManager.getService("authService");
const authState = await authService.getAuthState();
const isAuthenticated = await authService.isAuthenticated();
@ -20,7 +20,7 @@ export const authRouter = createRouter({
// Initiate login flow
login: procedure.mutation(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
const authService = ctx.serviceManager.getService("authService");
await authService.login();
@ -33,7 +33,7 @@ export const authRouter = createRouter({
// Logout
logout: procedure.mutation(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
const authService = ctx.serviceManager.getService("authService");
await authService.logout();
@ -45,7 +45,7 @@ export const authRouter = createRouter({
// Check if authenticated (for UI updates)
isAuthenticated: procedure.query(async ({ ctx }) => {
const authService = ctx.serviceManager.getService("authService")!;
const authService = ctx.serviceManager.getService("authService");
return await authService.isAuthenticated();
}),
@ -61,7 +61,7 @@ export const authRouter = createRouter({
userName: string | null;
error?: string;
}>((emit) => {
const authService = ctx.serviceManager.getService("authService")!;
const authService = ctx.serviceManager.getService("authService");
// Define handlers once (not in a loop)
const handleAuthenticated = async (authState: AuthState) => {
@ -121,14 +121,12 @@ export const authRouter = createRouter({
// Check if cloud model requires auth
isCloudModelSelected: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
)!;
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
return false;
}
const selectedModelId = await modelManagerService.getSelectedModel();
const selectedModelId = await modelService.getSelectedModel();
if (!selectedModelId) {
return false;
}

View file

@ -20,10 +20,8 @@ export const modelsRouter = createRouter({
}),
)
.query(async ({ input, ctx }): Promise<Model[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not available");
}
@ -31,12 +29,11 @@ export const modelsRouter = createRouter({
if (input.type === "speech") {
// 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();
const availableModels = modelService.getAvailableModels();
const downloadedModels = await modelService.getDownloadedModels();
// Check authentication status for cloud model filtering
const authService = ctx.serviceManager.getService("authService")!;
const authService = ctx.serviceManager.getService("authService");
const isAuthenticated = await authService.isAuthenticated();
// Map available models to Model format using downloaded data if available
@ -88,7 +85,7 @@ export const modelsRouter = createRouter({
}
// For language/embedding models (provider models)
let models = await modelManagerService.getSyncedProviderModels();
let models = await modelService.getSyncedProviderModels();
// Filter by provider if specified
if (input.provider) {
@ -116,22 +113,18 @@ export const modelsRouter = createRouter({
// Legacy endpoints (kept for backward compatibility)
getAvailableModels: procedure.query(
async ({ ctx }): Promise<AvailableWhisperModel[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService?.getAvailableModels() || [];
const modelService = ctx.serviceManager.getService("modelService");
return modelService?.getAvailableModels() || [];
},
),
getDownloadedModels: procedure.query(
async ({ ctx }): Promise<Record<string, Model>> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not available");
}
return await modelManagerService.getDownloadedModels();
return await modelService.getDownloadedModels();
},
),
@ -139,11 +132,9 @@ export const modelsRouter = createRouter({
isModelDownloaded: procedure
.input(z.object({ modelId: z.string() }))
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService
? await modelManagerService.isModelDownloaded(input.modelId)
const modelService = ctx.serviceManager.getService("modelService");
return modelService
? await modelService.isModelDownloaded(input.modelId)
: false;
}),
@ -151,105 +142,81 @@ export const modelsRouter = createRouter({
getDownloadProgress: procedure
.input(z.object({ modelId: z.string() }))
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService?.getDownloadProgress(input.modelId) || null;
const modelService = ctx.serviceManager.getService("modelService");
return modelService?.getDownloadProgress(input.modelId) || null;
}),
// Get active downloads
getActiveDownloads: procedure.query(
async ({ ctx }): Promise<DownloadProgress[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService?.getActiveDownloads() || [];
const modelService = ctx.serviceManager.getService("modelService");
return modelService?.getActiveDownloads() || [];
},
),
// Get models directory
getModelsDirectory: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService?.getModelsDirectory() || "";
const modelService = ctx.serviceManager.getService("modelService");
return modelService?.getModelsDirectory() || "";
}),
// Transcription model selection methods
isTranscriptionAvailable: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService
? await modelManagerService.isAvailable()
: false;
const modelService = ctx.serviceManager.getService("modelService");
return modelService ? await modelService.isAvailable() : false;
}),
getTranscriptionModels: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService
? await modelManagerService.getAvailableModelsForTranscription()
const modelService = ctx.serviceManager.getService("modelService");
return modelService
? await modelService.getAvailableModelsForTranscription()
: [];
}),
getSelectedModel: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
return modelManagerService
? await modelManagerService.getSelectedModel()
: null;
const modelService = ctx.serviceManager.getService("modelService");
return modelService ? await modelService.getSelectedModel() : null;
}),
// Mutations
downloadModel: procedure
.input(z.object({ modelId: z.string() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.downloadModel(input.modelId);
return await modelService.downloadModel(input.modelId);
}),
cancelDownload: procedure
.input(z.object({ modelId: z.string() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return modelManagerService.cancelDownload(input.modelId);
return modelService.cancelDownload(input.modelId);
}),
deleteModel: procedure
.input(z.object({ modelId: z.string() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return modelManagerService.deleteModel(input.modelId);
return modelService.deleteModel(input.modelId);
}),
setSelectedModel: procedure
.input(z.object({ modelId: z.string().nullable() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.setSelectedModel(input.modelId);
await modelService.setSelectedModel(input.modelId);
// Notify transcription service about model change
const transcriptionService = ctx.serviceManager.getService(
@ -266,64 +233,52 @@ export const modelsRouter = createRouter({
validateOpenRouterConnection: procedure
.input(z.object({ apiKey: z.string() }))
.mutation(async ({ input, ctx }): Promise<ValidationResult> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.validateOpenRouterConnection(
input.apiKey,
);
return await modelService.validateOpenRouterConnection(input.apiKey);
}),
validateOllamaConnection: procedure
.input(z.object({ url: z.string() }))
.mutation(async ({ input, ctx }): Promise<ValidationResult> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.validateOllamaConnection(input.url);
return await modelService.validateOllamaConnection(input.url);
}),
// Provider model fetching
fetchOpenRouterModels: procedure
.input(z.object({ apiKey: z.string() }))
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.fetchOpenRouterModels(input.apiKey);
return await modelService.fetchOpenRouterModels(input.apiKey);
}),
fetchOllamaModels: procedure
.input(z.object({ url: z.string() }))
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.fetchOllamaModels(input.url);
return await modelService.fetchOllamaModels(input.url);
}),
// Provider model database sync
getSyncedProviderModels: procedure.query(
async ({ ctx }): Promise<Model[]> => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.getSyncedProviderModels();
return await modelService.getSyncedProviderModels();
},
),
@ -335,13 +290,11 @@ export const modelsRouter = createRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.syncProviderModelsToDatabase(
await modelService.syncProviderModelsToDatabase(
input.provider,
input.models,
);
@ -356,20 +309,18 @@ export const modelsRouter = createRouter({
}),
)
.query(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
switch (input.type) {
case "speech":
return await modelManagerService.getSelectedModel();
return await modelService.getSelectedModel();
case "language":
return await modelManagerService.getDefaultLanguageModel();
return await modelService.getDefaultLanguageModel();
case "embedding":
return await modelManagerService.getDefaultEmbeddingModel();
return await modelService.getDefaultEmbeddingModel();
}
}),
@ -381,16 +332,14 @@ export const modelsRouter = createRouter({
}),
)
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
switch (input.type) {
case "speech":
await modelManagerService.setSelectedModel(input.modelId);
await modelService.setSelectedModel(input.modelId);
// Notify transcription service about model change
const transcriptionService = ctx.serviceManager.getService(
"transcriptionService",
@ -400,10 +349,10 @@ export const modelsRouter = createRouter({
}
break;
case "language":
await modelManagerService.setDefaultLanguageModel(input.modelId);
await modelService.setDefaultLanguageModel(input.modelId);
break;
case "embedding":
await modelManagerService.setDefaultEmbeddingModel(input.modelId);
await modelService.setDefaultEmbeddingModel(input.modelId);
break;
}
return true;
@ -411,48 +360,40 @@ export const modelsRouter = createRouter({
// Legacy endpoints (kept for backward compatibility, can be removed later)
getDefaultLanguageModel: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.getDefaultLanguageModel();
return await modelService.getDefaultLanguageModel();
}),
setDefaultLanguageModel: procedure
.input(z.object({ modelId: z.string().nullable() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.setDefaultLanguageModel(input.modelId);
await modelService.setDefaultLanguageModel(input.modelId);
return true;
}),
getDefaultEmbeddingModel: procedure.query(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
return await modelManagerService.getDefaultEmbeddingModel();
return await modelService.getDefaultEmbeddingModel();
}),
setDefaultEmbeddingModel: procedure
.input(z.object({ modelId: z.string().nullable() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
await modelManagerService.setDefaultEmbeddingModel(input.modelId);
await modelService.setDefaultEmbeddingModel(input.modelId);
return true;
}),
@ -460,15 +401,13 @@ export const modelsRouter = createRouter({
removeProviderModel: procedure
.input(z.object({ modelId: z.string() }))
.mutation(async ({ input, ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
// Find the model to get its provider
const allModels = await modelManagerService.getSyncedProviderModels();
const allModels = await modelService.getSyncedProviderModels();
const model = allModels.find((m) => m.id === input.modelId);
if (!model) {
@ -481,15 +420,13 @@ export const modelsRouter = createRouter({
// Remove provider endpoints
removeOpenRouterProvider: procedure.mutation(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
// Remove all OpenRouter models from database
await modelManagerService.removeProviderModels("OpenRouter");
await modelService.removeProviderModels("OpenRouter");
// Clear OpenRouter config from settings
const settingsService = ctx.serviceManager.getService("settingsService");
@ -499,7 +436,7 @@ export const modelsRouter = createRouter({
delete updatedConfig.openRouter;
// Clear default if it's an OpenRouter model
const allModels = await modelManagerService.getSyncedProviderModels();
const allModels = await modelService.getSyncedProviderModels();
const openRouterModels = allModels.filter(
(m) => m.provider === "OpenRouter",
);
@ -519,15 +456,13 @@ export const modelsRouter = createRouter({
}),
removeOllamaProvider: procedure.mutation(async ({ ctx }) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
// Remove all Ollama models from database
await modelManagerService.removeProviderModels("Ollama");
await modelService.removeProviderModels("Ollama");
// Clear Ollama config from settings
const settingsService = ctx.serviceManager.getService("settingsService");
@ -537,7 +472,7 @@ export const modelsRouter = createRouter({
delete updatedConfig.ollama;
// Clear defaults if they're Ollama models
const allModels = await modelManagerService.getSyncedProviderModels();
const allModels = await modelService.getSyncedProviderModels();
const ollamaModels = allModels.filter((m) => m.provider === "Ollama");
if (
@ -570,10 +505,8 @@ export const modelsRouter = createRouter({
onDownloadProgress: procedure.subscription(({ ctx }) => {
return observable<{ modelId: string; progress: DownloadProgress }>(
(emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
@ -584,11 +517,11 @@ export const modelsRouter = createRouter({
emit.next({ modelId, progress });
};
modelManagerService.on("download-progress", handleDownloadProgress);
modelService.on("download-progress", handleDownloadProgress);
// Cleanup function
return () => {
modelManagerService?.off("download-progress", handleDownloadProgress);
modelService?.off("download-progress", handleDownloadProgress);
};
},
);
@ -601,10 +534,8 @@ export const modelsRouter = createRouter({
modelId: string;
downloadedModel: Model;
}>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
@ -615,11 +546,11 @@ export const modelsRouter = createRouter({
emit.next({ modelId, downloadedModel });
};
modelManagerService.on("download-complete", handleDownloadComplete);
modelService.on("download-complete", handleDownloadComplete);
// Cleanup function
return () => {
modelManagerService?.off("download-complete", handleDownloadComplete);
modelService?.off("download-complete", handleDownloadComplete);
};
});
}),
@ -628,10 +559,8 @@ export const modelsRouter = createRouter({
// eslint-disable-next-line deprecation/deprecation
onDownloadError: procedure.subscription(({ ctx }) => {
return observable<{ modelId: string; error: string }>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
@ -639,11 +568,11 @@ export const modelsRouter = createRouter({
emit.next({ modelId, error: error.message });
};
modelManagerService.on("download-error", handleDownloadError);
modelService.on("download-error", handleDownloadError);
// Cleanup function
return () => {
modelManagerService?.off("download-error", handleDownloadError);
modelService?.off("download-error", handleDownloadError);
};
});
}),
@ -652,10 +581,8 @@ export const modelsRouter = createRouter({
// eslint-disable-next-line deprecation/deprecation
onDownloadCancelled: procedure.subscription(({ ctx }) => {
return observable<{ modelId: string }>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
@ -663,11 +590,11 @@ export const modelsRouter = createRouter({
emit.next({ modelId });
};
modelManagerService.on("download-cancelled", handleDownloadCancelled);
modelService.on("download-cancelled", handleDownloadCancelled);
// Cleanup function
return () => {
modelManagerService?.off("download-cancelled", handleDownloadCancelled);
modelService?.off("download-cancelled", handleDownloadCancelled);
};
});
}),
@ -676,10 +603,8 @@ export const modelsRouter = createRouter({
// eslint-disable-next-line deprecation/deprecation
onModelDeleted: procedure.subscription(({ ctx }) => {
return observable<{ modelId: string }>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
@ -687,11 +612,11 @@ export const modelsRouter = createRouter({
emit.next({ modelId });
};
modelManagerService.on("model-deleted", handleModelDeleted);
modelService.on("model-deleted", handleModelDeleted);
// Cleanup function
return () => {
modelManagerService?.off("model-deleted", handleModelDeleted);
modelService?.off("model-deleted", handleModelDeleted);
};
});
}),
@ -709,10 +634,8 @@ export const modelsRouter = createRouter({
| "cleared";
modelType: "speech" | "language" | "embedding";
}>((emit) => {
const modelManagerService = ctx.serviceManager.getService(
"modelManagerService",
);
if (!modelManagerService) {
const modelService = ctx.serviceManager.getService("modelService");
if (!modelService) {
throw new Error("Model manager service not initialized");
}
@ -729,11 +652,11 @@ export const modelsRouter = createRouter({
emit.next({ oldModelId, newModelId, reason, modelType });
};
modelManagerService.on("selection-changed", handleSelectionChanged);
modelService.on("selection-changed", handleSelectionChanged);
// Cleanup function
return () => {
modelManagerService?.off("selection-changed", handleSelectionChanged);
modelService?.off("selection-changed", handleSelectionChanged);
};
});
}),

View file

@ -1,18 +1,11 @@
import { z } from "zod";
import { systemPreferences, shell, app } from "electron";
import { createRouter, procedure } from "../trpc";
import { ServiceManager } from "../../main/managers/service-manager";
import {
OnboardingPreferencesSchema,
OnboardingStateSchema,
ModelTypeSchema,
FeatureInterestSchema,
DiscoverySourceSchema,
OnboardingScreenSchema,
type OnboardingState,
type ModelRecommendation,
type OnboardingFeatureFlags,
type OnboardingPreferences,
} from "../../types/onboarding";
import { logger } from "../../main/logger";
@ -24,9 +17,9 @@ export const onboardingRouter = createRouter({
/**
* Get current onboarding state from database
*/
getState: procedure.query(async () => {
getState: procedure.query(async ({ ctx }) => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
logger.main.warn("ServiceManager not available");
return null;
@ -50,9 +43,9 @@ export const onboardingRouter = createRouter({
* Get system recommendation for model selection
*/
getSystemRecommendation: procedure.query(
async (): Promise<ModelRecommendation> => {
async ({ ctx }): Promise<ModelRecommendation> => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
throw new Error("ServiceManager not available");
}
@ -75,9 +68,9 @@ export const onboardingRouter = createRouter({
/**
* Check if onboarding is needed
*/
needsOnboarding: procedure.query(async () => {
needsOnboarding: procedure.query(async ({ ctx }) => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
// If service manager not available, assume onboarding not needed
return {
@ -123,9 +116,9 @@ export const onboardingRouter = createRouter({
* Get feature flags for screen visibility
*/
getFeatureFlags: procedure.query(
async (): Promise<OnboardingFeatureFlags> => {
async ({ ctx }): Promise<OnboardingFeatureFlags> => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
// Return all screens enabled by default
return {
@ -172,9 +165,12 @@ export const onboardingRouter = createRouter({
savePreferences: procedure
.input(OnboardingPreferencesSchema)
.mutation(
async ({ input }): Promise<{ success: boolean; message?: string }> => {
async ({
input,
ctx,
}): Promise<{ success: boolean; message?: string }> => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
throw new Error("ServiceManager not available");
}
@ -198,168 +194,54 @@ export const onboardingRouter = createRouter({
},
),
/**
* Track onboarding started event
*/
trackOnboardingStarted: procedure
.input(
z.object({
platform: z.string(),
resumed: z.boolean(),
resumedFrom: z.string().optional(),
}),
)
.mutation(async ({ input }): Promise<void> => {
const serviceManager = ServiceManager.getInstance();
const telemetryService = serviceManager?.getService("telemetryService");
telemetryService?.trackOnboardingStarted(input);
}),
/**
* Track onboarding screen viewed event
*/
trackOnboardingScreenViewed: procedure
.input(
z.object({
screen: z.string(),
index: z.number(),
total: z.number(),
}),
)
.mutation(async ({ input }): Promise<void> => {
const serviceManager = ServiceManager.getInstance();
const telemetryService = serviceManager?.getService("telemetryService");
telemetryService?.trackOnboardingScreenViewed(input);
}),
/**
* Track onboarding features selected event
*/
trackOnboardingFeaturesSelected: procedure
.input(
z.object({
features: z.array(z.string()),
count: z.number(),
}),
)
.mutation(async ({ input }): Promise<void> => {
const serviceManager = ServiceManager.getInstance();
const telemetryService = serviceManager?.getService("telemetryService");
telemetryService?.trackOnboardingFeaturesSelected(input);
}),
/**
* Track onboarding discovery selected event
*/
trackOnboardingDiscoverySelected: procedure
.input(
z.object({
source: z.string(),
details: z.string().optional(),
}),
)
.mutation(async ({ input }): Promise<void> => {
const serviceManager = ServiceManager.getInstance();
const telemetryService = serviceManager?.getService("telemetryService");
telemetryService?.trackOnboardingDiscoverySelected(input);
}),
/**
* Track onboarding model selected event
*/
trackOnboardingModelSelected: procedure
.input(
z.object({
model_type: z.string(),
recommendation_followed: z.boolean(),
}),
)
.mutation(async ({ input }): Promise<void> => {
const serviceManager = ServiceManager.getInstance();
const telemetryService = serviceManager?.getService("telemetryService");
telemetryService?.trackOnboardingModelSelected(input);
}),
/**
* Track onboarding completed event
*/
trackOnboardingCompleted: procedure
.input(
z.object({
version: z.number(),
features_selected: z.array(z.string()),
discovery_source: z.string().optional(),
model_type: z.string(),
recommendation_followed: z.boolean(),
skipped_screens: z.array(z.string()).optional(),
}),
)
.mutation(async ({ input }): Promise<void> => {
const serviceManager = ServiceManager.getInstance();
const telemetryService = serviceManager?.getService("telemetryService");
telemetryService?.trackOnboardingCompleted(input);
}),
/**
* Complete onboarding and save final state
*/
complete: procedure
.input(OnboardingStateSchema)
.mutation(
async ({
input,
}): Promise<{ success: boolean; shouldRelaunch: boolean }> => {
try {
const serviceManager = ServiceManager.getInstance();
if (!serviceManager) {
throw new Error("ServiceManager not available");
}
const onboardingService = serviceManager.getOnboardingService();
const onboardingManager = serviceManager.getOnboardingManager();
if (!onboardingService || !onboardingManager) {
throw new Error("Onboarding services not available");
}
// Complete onboarding through the manager (handles window closing and relaunching)
await onboardingManager.completeOnboarding(input);
// Determine if app needs to relaunch
const isDevelopment = process.env.NODE_ENV === "development";
const shouldRelaunch = !isDevelopment;
logger.main.info("Onboarding completed successfully", {
shouldRelaunch,
state: input,
});
return {
success: true,
shouldRelaunch,
};
} catch (error) {
logger.main.error("Failed to complete onboarding:", error);
throw error;
.mutation(async ({ input, ctx }): Promise<{ success: boolean }> => {
try {
const { serviceManager } = ctx;
if (!serviceManager) {
throw new Error("ServiceManager not available");
}
},
),
const onboardingService = serviceManager.getOnboardingService();
if (!onboardingService) {
throw new Error("OnboardingService not available");
}
// Complete onboarding through the service
// AppManager handles window closing and relaunch decision
await onboardingService.completeOnboardingFlow(input);
logger.main.info("Onboarding completed successfully", {
state: input,
});
return { success: true };
} catch (error) {
logger.main.error("Failed to complete onboarding:", error);
throw error;
}
}),
/**
* Cancel onboarding
*/
cancel: procedure.mutation(async () => {
cancel: procedure.mutation(async ({ ctx }) => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
throw new Error("ServiceManager not available");
}
const onboardingManager = serviceManager.getOnboardingManager();
const onboardingService = serviceManager.getOnboardingService();
if (!onboardingManager) {
throw new Error("OnboardingManager not available");
if (!onboardingService) {
throw new Error("OnboardingService not available");
}
await onboardingManager.cancelOnboarding();
await onboardingService.cancelOnboardingFlow();
return { success: true };
} catch (error) {
@ -371,9 +253,9 @@ export const onboardingRouter = createRouter({
/**
* Reset onboarding state (for testing)
*/
reset: procedure.mutation(async () => {
reset: procedure.mutation(async ({ ctx }) => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
throw new Error("ServiceManager not available");
}
@ -396,9 +278,9 @@ export const onboardingRouter = createRouter({
/**
* Get skipped screens based on feature flags
*/
getSkippedScreens: procedure.query(async () => {
getSkippedScreens: procedure.query(async ({ ctx }) => {
try {
const serviceManager = ServiceManager.getInstance();
const { serviceManager } = ctx;
if (!serviceManager) {
return [];
}

View file

@ -541,14 +541,7 @@ export const settingsRouter = createRouter({
}
await settingsService.setPreferences(input);
// Sync widget visibility if preference changed
if (input.showWidgetWhileInactive !== undefined) {
const windowManager = ctx.serviceManager.getService("windowManager");
if (windowManager) {
await windowManager.syncWidgetVisibility();
}
}
// Window updates are handled via settings events in AppManager
return true;
}),
@ -570,12 +563,7 @@ export const settingsRouter = createRouter({
...currentUISettings,
theme: input.theme,
});
// Update all window themes immediately
const windowManager = ctx.serviceManager.getService("windowManager");
if (windowManager) {
await windowManager.updateAllWindowThemes();
}
// Window updates are handled via settings events in AppManager
const logger = ctx.serviceManager.getLogger();
if (logger) {

View file

@ -1,23 +0,0 @@
export interface OnboardingAPI {
// Permission checks
checkMicrophonePermission: () => Promise<string>;
checkAccessibilityPermission: () => Promise<boolean>;
// Permission requests
requestMicrophonePermission: () => Promise<boolean>;
requestAccessibilityPermission: () => Promise<void>;
// Window controls
quitApp: () => Promise<void>;
// System info
getPlatform: () => Promise<string>;
// External links
openExternal: (url: string) => Promise<void>;
// Logging
log: {
error: (...args: any[]) => Promise<void>;
};
}

View file

@ -21,6 +21,7 @@ export enum FeatureInterest {
ContextualDictation = "contextual_dictation",
NoteTaking = "note_taking",
MeetingTranscriptions = "meeting_transcriptions",
VoiceCommands = "voice_commands",
}
export enum DiscoverySource {
@ -39,13 +40,6 @@ export enum ModelType {
Local = "local",
}
export enum PermissionStatus {
Granted = "granted",
Denied = "denied",
NotDetermined = "not-determined",
Restricted = "restricted",
}
// ============================================================================
// Data Types
// ============================================================================
@ -71,6 +65,7 @@ export interface OnboardingPreferences {
discoverySource?: DiscoverySource;
selectedModelType?: ModelType;
modelRecommendation?: ModelRecommendation & { followed: boolean };
lastVisitedScreen?: OnboardingScreen;
}
export interface OnboardingState {
@ -88,30 +83,6 @@ export interface OnboardingState {
};
}
export interface AnalyticsEvent {
eventName: string;
properties: Record<string, any>;
}
// ============================================================================
// Navigation Types
// ============================================================================
export interface NavigationState {
currentScreen: OnboardingScreen;
completedScreens: OnboardingScreen[];
availableScreens: OnboardingScreen[];
canGoBack: boolean;
canGoNext: boolean;
}
export interface ScreenTransition {
from: OnboardingScreen;
to: OnboardingScreen;
action: "next" | "back" | "skip";
timestamp: number;
}
// ============================================================================
// Feature Flags
// ============================================================================
@ -139,7 +110,7 @@ export const OnboardingStateSchema = z.object({
completedVersion: z.number().min(1),
completedAt: z.string().datetime(),
skippedScreens: z.array(OnboardingScreenSchema).optional(),
featureInterests: z.array(FeatureInterestSchema).max(3).optional(),
featureInterests: z.array(FeatureInterestSchema).optional(),
discoverySource: DiscoverySourceSchema.optional(),
selectedModelType: ModelTypeSchema,
modelRecommendation: z
@ -152,127 +123,15 @@ export const OnboardingStateSchema = z.object({
});
export const OnboardingPreferencesSchema = z.object({
featureInterests: z.array(FeatureInterestSchema).max(3).optional(),
featureInterests: z.array(FeatureInterestSchema).optional(),
discoverySource: DiscoverySourceSchema.optional(),
selectedModelType: ModelTypeSchema.optional(),
followedRecommendation: z.boolean().optional(),
modelRecommendation: z
.object({
suggested: ModelTypeSchema,
reason: z.string(),
followed: z.boolean(),
})
.optional(),
lastVisitedScreen: OnboardingScreenSchema.optional(),
});
// ============================================================================
// Type Guards
// ============================================================================
export function isValidOnboardingState(data: unknown): data is OnboardingState {
return OnboardingStateSchema.safeParse(data).success;
}
export function isValidOnboardingPreferences(
data: unknown,
): data is OnboardingPreferences {
return OnboardingPreferencesSchema.safeParse(data).success;
}
export function isSkippableScreen(screen: OnboardingScreen): boolean {
return (
screen !== OnboardingScreen.Permissions &&
screen !== OnboardingScreen.Completion
);
}
// ============================================================================
// Helper Functions
// ============================================================================
export function getScreenOrder(): OnboardingScreen[] {
return [
OnboardingScreen.Welcome,
OnboardingScreen.Permissions,
OnboardingScreen.DiscoverySource,
OnboardingScreen.ModelSelection,
OnboardingScreen.Completion,
];
}
export function getNextScreen(
current: OnboardingScreen,
skippedScreens: OnboardingScreen[] = [],
): OnboardingScreen | null {
const order = getScreenOrder();
const currentIndex = order.indexOf(current);
for (let i = currentIndex + 1; i < order.length; i++) {
const nextScreen = order[i];
if (!skippedScreens.includes(nextScreen)) {
return nextScreen;
}
}
return null;
}
export function getPreviousScreen(
current: OnboardingScreen,
skippedScreens: OnboardingScreen[] = [],
): OnboardingScreen | null {
const order = getScreenOrder();
const currentIndex = order.indexOf(current);
for (let i = currentIndex - 1; i >= 0; i--) {
const prevScreen = order[i];
if (!skippedScreens.includes(prevScreen)) {
return prevScreen;
}
}
return null;
}
export function calculateProgress(
currentScreen: OnboardingScreen,
skippedScreens: OnboardingScreen[] = [],
): { current: number; total: number; percentage: number } {
const order = getScreenOrder();
const activeScreens = order.filter((s) => !skippedScreens.includes(s));
const currentIndex = activeScreens.indexOf(currentScreen) + 1;
const total = activeScreens.length;
return {
current: currentIndex,
total,
percentage: Math.round((currentIndex / total) * 100),
};
}
// ============================================================================
// Display Helpers
// ============================================================================
export const FEATURE_INTEREST_LABELS: Record<FeatureInterest, string> = {
[FeatureInterest.ContextualDictation]: "Contextual Dictation",
[FeatureInterest.NoteTaking]: "Note Taking",
[FeatureInterest.MeetingTranscriptions]: "Meeting Transcriptions",
};
export const DISCOVERY_SOURCE_LABELS: Record<DiscoverySource, string> = {
[DiscoverySource.SearchEngine]: "Search Engine (Google, Bing, etc.)",
[DiscoverySource.SocialMedia]: "Social Media (Twitter, LinkedIn, etc.)",
[DiscoverySource.WordOfMouth]: "Friend or Colleague",
[DiscoverySource.Advertisement]: "Online Advertisement",
[DiscoverySource.GitHub]: "GitHub",
[DiscoverySource.AIAssistant]: "AI Assistant",
[DiscoverySource.BlogArticle]: "Blog or Article",
[DiscoverySource.Other]: "Other",
};
export const MODEL_TYPE_LABELS: Record<ModelType, string> = {
[ModelType.Cloud]: "Cloud Processing",
[ModelType.Local]: "Local Processing",
};
export const SCREEN_TITLES: Record<OnboardingScreen, string> = {
[OnboardingScreen.Welcome]: "Welcome to Amical",
[OnboardingScreen.Permissions]: "Grant Permissions",
[OnboardingScreen.DiscoverySource]: "How did you find us?",
[OnboardingScreen.ModelSelection]: "Choose your processing mode",
[OnboardingScreen.Completion]: "You're all set!",
};

View file

@ -1,3 +1,4 @@
import ApplicationServices
import CoreGraphics
import Foundation