From fe318b80005c78fb9e24d24f29ec92352dfa939a Mon Sep 17 00:00:00 2001 From: Ivo Bellin Salarin Date: Sat, 4 Oct 2025 09:33:41 +0200 Subject: [PATCH 1/5] wip: drag and drop --- .../DependencyContainer+ViewModels.swift | 9 + .../DependencyContainer.swift | 2 + .../MenuBarPanelManager+DragDrop.swift | 58 ++++++ .../MenuBar/Manager/MenuBarPanelManager.swift | 19 ++ .../Manager/StatusBar/StatusBarManager.swift | 13 ++ .../UseCases/DragDrop/View/DragDropView.swift | 173 ++++++++++++++++++ .../ViewModel/DragDropViewModel.swift | 131 +++++++++++++ .../ViewModel/DragDropViewModelType.swift | 12 ++ 8 files changed, 417 insertions(+) create mode 100644 Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift create mode 100644 Recap/UseCases/DragDrop/View/DragDropView.swift create mode 100644 Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift create mode 100644 Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift diff --git a/Recap/DependencyContainer/DependencyContainer+ViewModels.swift b/Recap/DependencyContainer/DependencyContainer+ViewModels.swift index 1215851..d4797bf 100644 --- a/Recap/DependencyContainer/DependencyContainer+ViewModels.swift +++ b/Recap/DependencyContainer/DependencyContainer+ViewModels.swift @@ -39,4 +39,13 @@ extension DependencyContainer { userPreferencesRepository: userPreferencesRepository ) } + + func makeDragDropViewModel() -> DragDropViewModel { + DragDropViewModel( + transcriptionService: transcriptionService, + llmService: llmService, + userPreferencesRepository: userPreferencesRepository, + recordingFileManagerHelper: recordingFileManagerHelper + ) + } } diff --git a/Recap/DependencyContainer/DependencyContainer.swift b/Recap/DependencyContainer/DependencyContainer.swift index a0b26f8..28e8cf3 100644 --- a/Recap/DependencyContainer/DependencyContainer.swift +++ b/Recap/DependencyContainer/DependencyContainer.swift @@ -37,6 +37,7 @@ final class DependencyContainer { lazy var appSelectionCoordinator: AppSelectionCoordinatorType = makeAppSelectionCoordinator() lazy var keychainService: KeychainServiceType = makeKeychainService() lazy var keychainAPIValidator: KeychainAPIValidatorType = makeKeychainAPIValidator() + lazy var dragDropViewModel: DragDropViewModel = makeDragDropViewModel() init(inMemory: Bool = false) { self.inMemory = inMemory @@ -57,6 +58,7 @@ final class DependencyContainer { onboardingViewModel: onboardingViewModel, summaryViewModel: summaryViewModel, generalSettingsViewModel: generalSettingsViewModel, + dragDropViewModel: dragDropViewModel, userPreferencesRepository: userPreferencesRepository, meetingDetectionService: meetingDetectionService ) diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift new file mode 100644 index 0000000..8f1f662 --- /dev/null +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift @@ -0,0 +1,58 @@ +import AppKit +import SwiftUI + +extension MenuBarPanelManager { + func createDragDropPanel() -> SlidingPanel? { + let contentView = DragDropView( + viewModel: dragDropViewModel + ) { [weak self] in + self?.hideDragDropPanel() + } + let hostingController = NSHostingController(rootView: contentView) + hostingController.view.wantsLayer = true + hostingController.view.layer?.cornerRadius = 12 + + let newPanel = SlidingPanel(contentViewController: hostingController) + newPanel.panelDelegate = self + return newPanel + } + + func positionDragDropPanel(_ panel: NSPanel) { + guard let statusButton = statusBarManager.statusButton, + let statusWindow = statusButton.window, + let screen = statusWindow.screen + else { return } + + let screenFrame = screen.frame + let dragDropX = screenFrame.maxX - (initialSize.width * 2) - (panelOffset * 2) - panelSpacing + let panelY = screenFrame.maxY - menuBarHeight - initialSize.height - panelSpacing + + panel.setFrame( + NSRect(x: dragDropX, y: panelY, width: initialSize.width, height: initialSize.height), + display: false + ) + } + + func showDragDropPanel() { + if dragDropPanel == nil { + dragDropPanel = createDragDropPanel() + } + + guard let dragDropPanel = dragDropPanel else { return } + + positionDragDropPanel(dragDropPanel) + dragDropPanel.contentView?.wantsLayer = true + + PanelAnimator.slideIn(panel: dragDropPanel) { [weak self] in + self?.isDragDropVisible = true + } + } + + func hideDragDropPanel() { + guard let dragDropPanel = dragDropPanel else { return } + + PanelAnimator.slideOut(panel: dragDropPanel) { [weak self] in + self?.isDragDropVisible = false + } + } +} diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager.swift b/Recap/MenuBar/Manager/MenuBarPanelManager.swift index 8947a4f..b32ca76 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager.swift @@ -11,12 +11,14 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { var settingsPanel: SlidingPanel? var summaryPanel: SlidingPanel? var recapsPanel: SlidingPanel? + var dragDropPanel: SlidingPanel? var previousRecapsWindowManager: RecapsWindowManager? var isVisible = false var isSettingsVisible = false var isSummaryVisible = false var isRecapsVisible = false + var isDragDropVisible = false var isPreviousRecapsVisible = false let initialSize = CGSize(width: 485, height: 500) @@ -34,6 +36,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { let onboardingViewModel: OnboardingViewModel let summaryViewModel: SummaryViewModel let generalSettingsViewModel: GeneralSettingsViewModel + let dragDropViewModel: DragDropViewModel let userPreferencesRepository: UserPreferencesRepositoryType let meetingDetectionService: any MeetingDetectionServiceType private let logger = Logger( @@ -51,6 +54,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { onboardingViewModel: OnboardingViewModel, summaryViewModel: SummaryViewModel, generalSettingsViewModel: GeneralSettingsViewModel, + dragDropViewModel: DragDropViewModel, userPreferencesRepository: UserPreferencesRepositoryType, meetingDetectionService: any MeetingDetectionServiceType ) { @@ -62,6 +66,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { self.onboardingViewModel = onboardingViewModel self.summaryViewModel = summaryViewModel self.generalSettingsViewModel = generalSettingsViewModel + self.dragDropViewModel = dragDropViewModel self.userPreferencesRepository = userPreferencesRepository self.meetingDetectionService = meetingDetectionService self.previousRecapsViewModel = previousRecapsViewModel @@ -193,6 +198,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { if isSettingsVisible { hideSettingsPanel() } if isSummaryVisible { hideSummaryPanel() } if isRecapsVisible { hideRecapsPanel() } + if isDragDropVisible { hideDragDropPanel() } if isPreviousRecapsVisible { hidePreviousRecapsWindow() } } @@ -210,6 +216,7 @@ final class MenuBarPanelManager: MenuBarPanelManagerType, ObservableObject { panel = nil settingsPanel = nil recapsPanel = nil + dragDropPanel = nil } } @@ -259,6 +266,18 @@ extension MenuBarPanelManager: StatusBarDelegate { ) } + func dragDropRequested() { + // Hide main panel and show only drag & drop panel + if isVisible { + hidePanel() + } + toggleSidePanel( + isVisible: isDragDropVisible, + show: showDragDropPanel, + hide: hideDragDropPanel + ) + } + func quitRequested() { NSApplication.shared.terminate(nil) } diff --git a/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift b/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift index c8e88ed..92ade08 100644 --- a/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift +++ b/Recap/MenuBar/Manager/StatusBar/StatusBarManager.swift @@ -9,6 +9,7 @@ protocol StatusBarDelegate: AnyObject { func stopRecordingRequested() func settingsRequested() func recapsRequested() + func dragDropRequested() } final class StatusBarManager: StatusBarManagerType { @@ -138,6 +139,11 @@ final class StatusBarManager: StatusBarManagerType { title: "Recaps", action: #selector(recapsMenuItemClicked), keyEquivalent: "") recapsItem.target = self + // Drag & Drop menu item + let dragDropItem = NSMenuItem( + title: "Drag & Drop", action: #selector(dragDropMenuItemClicked), keyEquivalent: "") + dragDropItem.target = self + // Settings menu item let settingsItem = NSMenuItem( title: "Settings", action: #selector(settingsMenuItemClicked), keyEquivalent: "") @@ -150,6 +156,7 @@ final class StatusBarManager: StatusBarManagerType { mainMenu.addItem(recordingItem) mainMenu.addItem(recapsItem) + mainMenu.addItem(dragDropItem) mainMenu.addItem(settingsItem) mainMenu.addItem(NSMenuItem.separator()) mainMenu.addItem(quitItem) @@ -197,6 +204,12 @@ final class StatusBarManager: StatusBarManagerType { } } + @objc private func dragDropMenuItemClicked() { + DispatchQueue.main.async { [weak self] in + self?.delegate?.dragDropRequested() + } + } + @objc private func quitMenuItemClicked() { DispatchQueue.main.async { [weak self] in self?.delegate?.quitRequested() diff --git a/Recap/UseCases/DragDrop/View/DragDropView.swift b/Recap/UseCases/DragDrop/View/DragDropView.swift new file mode 100644 index 0000000..b4309e7 --- /dev/null +++ b/Recap/UseCases/DragDrop/View/DragDropView.swift @@ -0,0 +1,173 @@ +import SwiftUI +import UniformTypeIdentifiers + +struct DragDropView: View { + @ObservedObject var viewModel: ViewModel + let onClose: () -> Void + + @State private var isDragging = false + + var body: some View { + GeometryReader { _ in + ZStack { + UIConstants.Gradients.backgroundGradient + .ignoresSafeArea() + + VStack(spacing: UIConstants.Spacing.sectionSpacing) { + // Header with close button + HStack { + Text("Drag & Drop") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(UIConstants.Typography.appTitle) + .padding(.leading, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + + Spacer() + + Text("Close") + .font(.system(size: 10, weight: .medium)) + .foregroundColor(.white) + .padding(.horizontal, 12) + .padding(.vertical, 10) + .background( + RoundedRectangle(cornerRadius: 20) + .fill(Color(hex: "242323")) + .overlay( + RoundedRectangle(cornerRadius: 20) + .stroke( + LinearGradient( + gradient: Gradient(stops: [ + .init( + color: Color(hex: "979797").opacity( + 0.6), location: 0), + .init( + color: Color(hex: "979797").opacity( + 0.4), location: 1) + ]), + startPoint: .top, + endPoint: .bottom + ), + lineWidth: 0.8 + ) + ) + .opacity(0.6) + ) + .onTapGesture { + onClose() + } + .padding(.trailing, UIConstants.Spacing.contentPadding) + .padding(.top, UIConstants.Spacing.sectionSpacing) + } + + // Checkboxes + HStack(spacing: 16) { + Toggle(isOn: $viewModel.transcriptEnabled) { + Text("Transcript") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(.system(size: 14, weight: .medium)) + } + .toggleStyle(.checkbox) + + Toggle(isOn: $viewModel.summarizeEnabled) { + Text("Summarize") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(.system(size: 14, weight: .medium)) + } + .toggleStyle(.checkbox) + + Spacer() + } + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .disabled(viewModel.isProcessing) + + // Drop zone + ZStack { + RoundedRectangle(cornerRadius: 12) + .stroke( + isDragging ? Color.blue : Color.gray.opacity(0.5), + style: StrokeStyle(lineWidth: 2, dash: [10, 5]) + ) + .background( + RoundedRectangle(cornerRadius: 12) + .fill( + isDragging + ? Color.blue.opacity(0.1) + : Color.black.opacity(0.2) + ) + ) + + VStack(spacing: 16) { + Image(systemName: isDragging ? "arrow.down.circle.fill" : "waveform.circle") + .font(.system(size: 48)) + .foregroundColor(isDragging ? .blue : .gray) + + if viewModel.isProcessing { + ProgressView() + .scaleEffect(1.2) + .padding(.bottom, 8) + + Text("Processing...") + .foregroundColor(UIConstants.Colors.textSecondary) + .font(.system(size: 14, weight: .medium)) + } else { + Text(isDragging ? "Drop here" : "Drop audio file here") + .foregroundColor(UIConstants.Colors.textPrimary) + .font(.system(size: 16, weight: .semibold)) + + Text("Supported formats: wav, mp3, m4a, flac") + .foregroundColor(UIConstants.Colors.textSecondary) + .font(.system(size: 12)) + } + } + } + .frame(maxWidth: .infinity, maxHeight: .infinity) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, UIConstants.Spacing.sectionSpacing) + .onDrop( + of: [.fileURL], + isTargeted: $isDragging + ) { providers in + handleDrop(providers: providers) + } + + // Messages + if let error = viewModel.errorMessage { + Text(error) + .foregroundColor(.red) + .font(.system(size: 12)) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, 8) + .multilineTextAlignment(.center) + } + + if let success = viewModel.successMessage { + Text(success) + .foregroundColor(.green) + .font(.system(size: 12)) + .padding(.horizontal, UIConstants.Spacing.contentPadding) + .padding(.bottom, 8) + .multilineTextAlignment(.center) + .lineLimit(3) + } + } + } + } + } + + private func handleDrop(providers: [NSItemProvider]) -> Bool { + guard let provider = providers.first else { return false } + + provider.loadItem(forTypeIdentifier: UTType.fileURL.identifier, options: nil) { + item, _ in + guard let data = item as? Data, + let url = URL(dataRepresentation: data, relativeTo: nil) + else { return } + + Task { @MainActor in + await viewModel.handleDroppedFile(url: url) + } + } + + return true + } +} diff --git a/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift new file mode 100644 index 0000000..f528bfc --- /dev/null +++ b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift @@ -0,0 +1,131 @@ +import Foundation +import OSLog + +@MainActor +final class DragDropViewModel: DragDropViewModelType { + @Published var transcriptEnabled: Bool + @Published var summarizeEnabled: Bool + @Published var isProcessing = false + @Published var errorMessage: String? + @Published var successMessage: String? + + private let transcriptionService: TranscriptionServiceType + private let llmService: LLMServiceType + private let userPreferencesRepository: UserPreferencesRepositoryType + private let recordingFileManagerHelper: RecordingFileManagerHelperType + private let logger = Logger( + subsystem: AppConstants.Logging.subsystem, + category: String(describing: DragDropViewModel.self)) + + init( + transcriptionService: TranscriptionServiceType, + llmService: LLMServiceType, + userPreferencesRepository: UserPreferencesRepositoryType, + recordingFileManagerHelper: RecordingFileManagerHelperType + ) { + self.transcriptionService = transcriptionService + self.llmService = llmService + self.userPreferencesRepository = userPreferencesRepository + self.recordingFileManagerHelper = recordingFileManagerHelper + + // Initialize with defaults, will be loaded async + self.transcriptEnabled = true + self.summarizeEnabled = true + + // Load user preferences asynchronously + Task { + if let prefs = try? await userPreferencesRepository.getOrCreatePreferences() { + await MainActor.run { + self.transcriptEnabled = prefs.autoTranscribeEnabled + self.summarizeEnabled = prefs.autoSummarizeEnabled + } + } + } + } + + func handleDroppedFile(url: URL) async { + errorMessage = nil + successMessage = nil + isProcessing = true + + do { + // Validate file format + let fileExtension = url.pathExtension.lowercased() + let supportedFormats = ["wav", "mp3", "m4a", "flac"] + + guard supportedFormats.contains(fileExtension) else { + throw DragDropError.unsupportedFormat(fileExtension) + } + + // Create unique identifier with timestamp + let timestamp = ISO8601DateFormatter().string(from: Date()) + .replacingOccurrences(of: ":", with: "-") + .replacingOccurrences(of: ".", with: "-") + let recordingID = "drag_drop_\(timestamp)" + + // Get storage directory using helper + let recordingDirectory = try recordingFileManagerHelper.createRecordingDirectory( + for: recordingID) + + // Copy audio file to storage + let destinationURL = recordingDirectory.appendingPathComponent("system_recording.wav") + try FileManager.default.copyItem(at: url, to: destinationURL) + + logger.info("Copied audio file to: \(destinationURL.path, privacy: .public)") + + var transcriptionText: String? + + // Transcribe if enabled + if transcriptEnabled { + logger.info("Starting transcription for drag & drop file") + let result = try await transcriptionService.transcribe( + audioURL: destinationURL, microphoneURL: nil) + transcriptionText = result.combinedText + + // Save transcript to markdown + let transcriptURL = recordingDirectory.appendingPathComponent("transcript.md") + try result.combinedText.write(to: transcriptURL, atomically: true, encoding: .utf8) + logger.info("Saved transcript to: \(transcriptURL.path, privacy: .public)") + } + + // Summarize if enabled and we have a transcript + if summarizeEnabled, let text = transcriptionText { + logger.info("Starting summarization for drag & drop file") + + let summary = try await llmService.generateSummarization( + text: text, + options: .defaultSummarization + ) + + // Save summary to markdown + let summaryURL = recordingDirectory.appendingPathComponent("summary.md") + try summary.write(to: summaryURL, atomically: true, encoding: String.Encoding.utf8) + logger.info("Saved summary to: \(summaryURL.path, privacy: .public)") + } + + successMessage = "File processed successfully! Saved to: \(recordingDirectory.path)" + logger.info("✅ Drag & drop processing complete: \(recordingID, privacy: .public)") + + } catch let error as DragDropError { + errorMessage = error.localizedDescription + logger.error("❌ Drag & drop error: \(error.localizedDescription, privacy: .public)") + } catch { + errorMessage = "Failed to process file: \(error.localizedDescription)" + logger.error( + "❌ Unexpected error in drag & drop: \(error.localizedDescription, privacy: .public)") + } + + isProcessing = false + } +} + +enum DragDropError: LocalizedError { + case unsupportedFormat(String) + + var errorDescription: String? { + switch self { + case .unsupportedFormat(let format): + return "Unsupported audio format: .\(format). Supported formats: wav, mp3, m4a, flac" + } + } +} diff --git a/Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift new file mode 100644 index 0000000..8ca9541 --- /dev/null +++ b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModelType.swift @@ -0,0 +1,12 @@ +import Foundation + +@MainActor +protocol DragDropViewModelType: ObservableObject { + var transcriptEnabled: Bool { get set } + var summarizeEnabled: Bool { get set } + var isProcessing: Bool { get } + var errorMessage: String? { get } + var successMessage: String? { get } + + func handleDroppedFile(url: URL) async +} From 517020b5857625cc33b4fda4501c555d0f67f535 Mon Sep 17 00:00:00 2001 From: Ivo Bellin Salarin Date: Sat, 4 Oct 2025 10:06:26 +0200 Subject: [PATCH 2/5] feat: button to test the openai and openrouter providers --- Recap/Services/LLM/LLMService.swift | 56 +++++++++++++- Recap/Services/LLM/LLMServiceType.swift | 1 + .../GeneralSettingsView+Preview.swift | 8 ++ .../TabViews/GeneralSettingsView.swift | 30 ++++++++ .../GeneralSettingsViewModel+APIKeys.swift | 6 ++ .../General/GeneralSettingsViewModel.swift | 77 +++++++++++++++++++ .../GeneralSettingsViewModelType.swift | 3 + 7 files changed, 179 insertions(+), 2 deletions(-) diff --git a/Recap/Services/LLM/LLMService.swift b/Recap/Services/LLM/LLMService.swift index c43dc9c..019f3e4 100644 --- a/Recap/Services/LLM/LLMService.swift +++ b/Recap/Services/LLM/LLMService.swift @@ -32,12 +32,14 @@ final class LLMService: LLMServiceType { func initializeProviders() { let ollamaProvider = OllamaProvider() - let openRouterProvider = OpenRouterProvider() - // Get OpenAI credentials from keychain + // Get credentials from keychain let keychainService = KeychainService() + let openRouterApiKey = try? keychainService.retrieveOpenRouterAPIKey() let openAIApiKey = try? keychainService.retrieveOpenAIAPIKey() let openAIEndpoint = try? keychainService.retrieveOpenAIEndpoint() + + let openRouterProvider = OpenRouterProvider(apiKey: openRouterApiKey) let openAIProvider = OpenAIProvider( apiKey: openAIApiKey, endpoint: openAIEndpoint ?? "https://api.openai.com/v1" @@ -73,6 +75,56 @@ final class LLMService: LLMServiceType { } } + func reinitializeProviders() { + // Cancel any existing subscriptions + cancellables.removeAll() + + // Get fresh credentials from keychain + let keychainService = KeychainService() + let openRouterApiKey = try? keychainService.retrieveOpenRouterAPIKey() + let openAIApiKey = try? keychainService.retrieveOpenAIAPIKey() + let openAIEndpoint = try? keychainService.retrieveOpenAIEndpoint() + + // Create new provider instances with updated credentials + let ollamaProvider = OllamaProvider() + let openRouterProvider = OpenRouterProvider(apiKey: openRouterApiKey) + let openAIProvider = OpenAIProvider( + apiKey: openAIApiKey, + endpoint: openAIEndpoint ?? "https://api.openai.com/v1" + ) + + availableProviders = [ollamaProvider, openRouterProvider, openAIProvider] + + // Update current provider + Task { + do { + let preferences = try await userPreferencesRepository.getOrCreatePreferences() + setCurrentProvider(preferences.selectedProvider) + } catch { + setCurrentProvider(.default) + } + } + + // Re-setup availability monitoring + Publishers.CombineLatest3( + ollamaProvider.availabilityPublisher, + openRouterProvider.availabilityPublisher, + openAIProvider.availabilityPublisher + ) + .map { ollamaAvailable, openRouterAvailable, openAIAvailable in + ollamaAvailable || openRouterAvailable || openAIAvailable + } + .sink { [weak self] isAnyProviderAvailable in + self?.isProviderAvailable = isAnyProviderAvailable + } + .store(in: &cancellables) + + // Refresh models from providers + Task { + try? await refreshModelsFromProviders() + } + } + func refreshModelsFromProviders() async throws { var allModelInfos: [LLMModelInfo] = [] diff --git a/Recap/Services/LLM/LLMServiceType.swift b/Recap/Services/LLM/LLMServiceType.swift index c9b9e40..ac49fd7 100644 --- a/Recap/Services/LLM/LLMServiceType.swift +++ b/Recap/Services/LLM/LLMServiceType.swift @@ -16,6 +16,7 @@ protocol LLMServiceType: AnyObject { var providerAvailabilityPublisher: AnyPublisher { get } func initializeProviders() + func reinitializeProviders() func refreshModelsFromProviders() async throws func getAvailableModels() async throws -> [LLMModelInfo] func getSelectedModel() async throws -> LLMModelInfo? diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift index 6650a54..fa17b98 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView+Preview.swift @@ -36,6 +36,8 @@ import SwiftUI @Published var existingOpenAIEndpoint: String? @Published var globalShortcutKeyCode: Int32 = 15 @Published var globalShortcutModifiers: Int32 = 1_048_840 + @Published var isTestingProvider = false + @Published var testResult: String? @Published var activeWarnings: [WarningItem] = [ WarningItem( id: "ollama", @@ -94,6 +96,12 @@ import SwiftUI globalShortcutKeyCode = keyCode globalShortcutModifiers = modifiers } + func testLLMProvider() async { + isTestingProvider = true + try? await Task.sleep(nanoseconds: 1_000_000_000) + testResult = "✓ Test successful!\n\nSummary:\nPreview mode - test functionality works!" + isTestingProvider = false + } } final class PreviewFolderSettingsViewModel: FolderSettingsViewModelType { diff --git a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift index b8bca06..98cb13e 100644 --- a/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift +++ b/Recap/UseCases/Settings/Components/TabViews/GeneralSettingsView.swift @@ -63,6 +63,36 @@ struct GeneralSettingsView: View { modelSelectionContent() + HStack { + Spacer() + + PillButton( + text: viewModel.isTestingProvider ? "Testing..." : "Test LLM Provider", + icon: viewModel.isTestingProvider ? nil : "checkmark.circle" + ) { + Task { + await viewModel.testLLMProvider() + } + } + .disabled(viewModel.isTestingProvider) + } + + if let testResult = viewModel.testResult { + Text(testResult) + .font(.system(size: 11, weight: .regular)) + .foregroundColor(UIConstants.Colors.textSecondary) + .padding(12) + .frame(maxWidth: .infinity, alignment: .leading) + .background( + RoundedRectangle(cornerRadius: 8) + .fill(Color(hex: "1A1A1A")) + .overlay( + RoundedRectangle(cornerRadius: 8) + .stroke(Color(hex: "2A2A2A"), lineWidth: 1) + ) + ) + } + if let errorMessage = viewModel.errorMessage { Text(errorMessage) .font(.system(size: 11, weight: .medium)) diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift index ec81fb9..1fc32b8 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel+APIKeys.swift @@ -8,6 +8,9 @@ extension GeneralSettingsViewModel { existingAPIKey = apiKey showAPIKeyAlert = false + // Reinitialize providers with new credentials + llmService.reinitializeProviders() + await selectProvider(.openRouter) } @@ -24,6 +27,9 @@ extension GeneralSettingsViewModel { existingOpenAIEndpoint = endpoint showOpenAIAlert = false + // Reinitialize providers with new credentials + llmService.reinitializeProviders() + await selectProvider(.openAI) } diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift index 5dd0546..b202254 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModel.swift @@ -48,6 +48,8 @@ final class GeneralSettingsViewModel: GeneralSettingsViewModelType { @Published var showOpenAIAlert = false @Published var existingOpenAIKey: String? @Published var existingOpenAIEndpoint: String? + @Published var isTestingProvider = false + @Published var testResult: String? var hasModels: Bool { !availableModels.isEmpty @@ -232,4 +234,79 @@ final class GeneralSettingsViewModel: GeneralSettingsViewModelType { } } + func testLLMProvider() async { + errorMessage = nil + testResult = nil + isTestingProvider = true + + defer { + isTestingProvider = false + } + + // Create boilerplate transcription data + let boilerplateTranscript = """ + Speaker 1: Good morning everyone, thank you for joining today's meeting. + Speaker 2: Thanks for having us. I wanted to discuss our Q4 roadmap. + Speaker 1: Absolutely. Let's start with the main priorities. + Speaker 2: We need to focus on three key areas: product launch, marketing campaign, and customer feedback integration. + Speaker 1: Agreed. For the product launch, we're targeting mid-November. + Speaker 2: That timeline works well with our marketing plans. + Speaker 1: Great. Any concerns or questions? + Speaker 2: No, I think we're aligned. Let's schedule a follow-up next week. + Speaker 1: Perfect, I'll send out calendar invites. Thanks everyone! + """ + + let metadata = TranscriptMetadata( + duration: 180, // 3 minutes + participants: ["Speaker 1", "Speaker 2"], + recordingDate: Date(), + applicationName: "Test" + ) + + let options = SummarizationOptions( + style: .concise, + includeActionItems: true, + includeKeyPoints: true, + maxLength: nil, + customPrompt: customPromptTemplateValue.isEmpty ? nil : customPromptTemplateValue + ) + + let request = SummarizationRequest( + transcriptText: boilerplateTranscript, + metadata: metadata, + options: options + ) + + do { + let result = try await llmService.generateSummarization( + text: await buildTestPrompt(from: request), + options: LLMOptions(temperature: 0.7, maxTokens: 500, keepAliveMinutes: 5) + ) + + testResult = "✓ Test successful!\n\nSummary:\n\(result)" + } catch { + errorMessage = "Test failed: \(error.localizedDescription)" + } + } + + private func buildTestPrompt(from request: SummarizationRequest) async -> String { + var prompt = "" + + if let metadata = request.metadata { + prompt += "Context:\n" + if let appName = metadata.applicationName { + prompt += "- Application: \(appName)\n" + } + prompt += "- Duration: 3 minutes\n" + if let participants = metadata.participants, !participants.isEmpty { + prompt += "- Participants: \(participants.joined(separator: ", "))\n" + } + prompt += "\n" + } + + prompt += "Transcript:\n\(request.transcriptText)" + + return prompt + } + } diff --git a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift index bea6c0e..4185c1f 100644 --- a/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift +++ b/Recap/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelType.swift @@ -28,6 +28,8 @@ protocol GeneralSettingsViewModelType: ObservableObject { var globalShortcutModifiers: Int32 { get } var folderSettingsViewModel: FolderSettingsViewModelType { get } var manualModelName: Binding { get } + var isTestingProvider: Bool { get } + var testResult: String? { get } func loadModels() async func selectModel(_ model: LLMModelInfo) async @@ -44,4 +46,5 @@ protocol GeneralSettingsViewModelType: ObservableObject { func saveOpenAIConfiguration(apiKey: String, endpoint: String) async throws func dismissOpenAIAlert() func updateGlobalShortcut(keyCode: Int32, modifiers: Int32) async + func testLLMProvider() async } From e5e55a833a61e3944f82ef29fea6263f88f06d3c Mon Sep 17 00:00:00 2001 From: Ivo Bellin Salarin Date: Sat, 4 Oct 2025 10:12:41 +0200 Subject: [PATCH 3/5] fix: keep the drag and drop panel open on drag --- Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift | 5 ++++- Recap/MenuBar/SlidingPanel.swift | 5 ++++- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift b/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift index 8f1f662..dff52d7 100644 --- a/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift +++ b/Recap/MenuBar/Manager/MenuBarPanelManager+DragDrop.swift @@ -12,7 +12,10 @@ extension MenuBarPanelManager { hostingController.view.wantsLayer = true hostingController.view.layer?.cornerRadius = 12 - let newPanel = SlidingPanel(contentViewController: hostingController) + let newPanel = SlidingPanel( + contentViewController: hostingController, + shouldCloseOnOutsideClick: false + ) newPanel.panelDelegate = self return newPanel } diff --git a/Recap/MenuBar/SlidingPanel.swift b/Recap/MenuBar/SlidingPanel.swift index c4447a3..2c6ddef 100644 --- a/Recap/MenuBar/SlidingPanel.swift +++ b/Recap/MenuBar/SlidingPanel.swift @@ -8,8 +8,10 @@ protocol SlidingPanelDelegate: AnyObject { final class SlidingPanel: NSPanel, SlidingPanelType { weak var panelDelegate: SlidingPanelDelegate? private var eventMonitor: Any? + var shouldCloseOnOutsideClick: Bool = true - init(contentViewController: NSViewController) { + init(contentViewController: NSViewController, shouldCloseOnOutsideClick: Bool = true) { + self.shouldCloseOnOutsideClick = shouldCloseOnOutsideClick super.init( contentRect: .zero, styleMask: [.borderless, .nonactivatingPanel], @@ -78,6 +80,7 @@ final class SlidingPanel: NSPanel, SlidingPanelType { } private func handleGlobalClick(_ event: NSEvent) { + guard shouldCloseOnOutsideClick else { return } let globalLocation = NSEvent.mouseLocation if !self.frame.contains(globalLocation) { panelDelegate?.panelDidReceiveClickOutside() From fe25b703e2035fffd8c7039a2285eaa99a3e624a Mon Sep 17 00:00:00 2001 From: Ivo Bellin Salarin Date: Sat, 4 Oct 2025 10:14:50 +0200 Subject: [PATCH 4/5] feat: apply the same formatting as recording transcripts --- .../ViewModel/DragDropViewModel.swift | 64 +++++++++++++++++-- 1 file changed, 60 insertions(+), 4 deletions(-) diff --git a/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift index f528bfc..d55374e 100644 --- a/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift +++ b/Recap/UseCases/DragDrop/ViewModel/DragDropViewModel.swift @@ -74,6 +74,7 @@ final class DragDropViewModel: DragDropViewModelType { logger.info("Copied audio file to: \(destinationURL.path, privacy: .public)") var transcriptionText: String? + var transcriptionResult: TranscriptionResult? // Transcribe if enabled if transcriptEnabled { @@ -81,10 +82,15 @@ final class DragDropViewModel: DragDropViewModelType { let result = try await transcriptionService.transcribe( audioURL: destinationURL, microphoneURL: nil) transcriptionText = result.combinedText - - // Save transcript to markdown - let transcriptURL = recordingDirectory.appendingPathComponent("transcript.md") - try result.combinedText.write(to: transcriptURL, atomically: true, encoding: .utf8) + transcriptionResult = result + + // Save transcript to markdown with proper formatting + let transcriptURL = try saveFormattedTranscript( + result: result, + recordingDirectory: recordingDirectory, + audioURL: destinationURL, + startDate: Date() + ) logger.info("Saved transcript to: \(transcriptURL.path, privacy: .public)") } @@ -117,6 +123,56 @@ final class DragDropViewModel: DragDropViewModelType { isProcessing = false } + + private func saveFormattedTranscript( + result: TranscriptionResult, + recordingDirectory: URL, + audioURL: URL, + startDate: Date + ) throws -> URL { + var markdown = "" + + // Title + let dateFormatter = DateFormatter() + dateFormatter.dateFormat = "yyyy-MM-dd_HH-mm-ss-SSS" + let dateString = dateFormatter.string(from: startDate) + markdown += "# Transcription - \(dateString)\n\n" + + // Metadata + let generatedFormatter = ISO8601DateFormatter() + generatedFormatter.formatOptions = [.withInternetDateTime, .withFractionalSeconds] + markdown += "**Generated:** \(generatedFormatter.string(from: Date()))\n" + + // Duration from transcription result + markdown += "**Duration:** \(String(format: "%.2f", result.transcriptionDuration))s\n" + + // Model used + markdown += "**Model:** \(result.modelUsed)\n" + + // Sources (for drag & drop, it's always system audio only) + markdown += "**Sources:** System Audio\n" + + // Transcript section + markdown += "## Transcript\n\n" + + // Format transcript using timestamped data if available, otherwise use combined text + if let timestampedTranscription = result.timestampedTranscription { + let formattedTranscript = TranscriptionMerger.getFormattedTranscript(timestampedTranscription) + markdown += formattedTranscript + } else { + // Fallback to combined text if no timestamped data + markdown += result.combinedText + } + + markdown += "\n" + + // Save to file + let filename = "transcription_\(dateString).md" + let fileURL = recordingDirectory.appendingPathComponent(filename) + try markdown.write(to: fileURL, atomically: true, encoding: .utf8) + + return fileURL + } } enum DragDropError: LocalizedError { From 04f31e7fbe5d9737545e1cbdba8cb6a69dc83e52 Mon Sep 17 00:00:00 2001 From: Ivo Bellin Salarin Date: Sat, 4 Oct 2025 11:15:13 +0200 Subject: [PATCH 5/5] fix(unit-tests): missing mocks --- .../General/GeneralSettingsViewModelSpec+APIKeys.swift | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift index fc89103..445a1b3 100644 --- a/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift +++ b/RecapTests/UseCases/Settings/ViewModels/General/GeneralSettingsViewModelSpec+APIKeys.swift @@ -13,6 +13,10 @@ extension GeneralSettingsViewModelSpec { .store(key: .value(KeychainKey.openRouterApiKey.key), value: .value("test-api-key")) .willReturn() + given(mockLLMService) + .reinitializeProviders() + .willReturn() + given(mockKeychainAPIValidator) .validateOpenRouterAPI() .willReturn(.valid)