chore: update ob copy + resolve circular deps
This commit is contained in:
parent
0e72fb2eb6
commit
deed3c4de4
39 changed files with 939 additions and 1759 deletions
60
.env.example
60
.env.example
|
|
@ -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
|
||||
|
|
@ -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[]) => {
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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());
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
}
|
||||
|
|
@ -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", {})
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.",
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
|
|
@ -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 };
|
||||
|
|
@ -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();
|
||||
|
|
|
|||
|
|
@ -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");
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
};
|
||||
});
|
||||
}),
|
||||
|
|
|
|||
|
|
@ -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 [];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>;
|
||||
};
|
||||
}
|
||||
|
|
@ -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!",
|
||||
};
|
||||
|
|
|
|||
|
|
@ -1,3 +1,4 @@
|
|||
import ApplicationServices
|
||||
import CoreGraphics
|
||||
import Foundation
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue