diff --git a/README.md b/README.md index 86f3f90..01cd45a 100644 --- a/README.md +++ b/README.md @@ -260,6 +260,10 @@ Helped add the start-on-boot setting! ### macOS version: #### A native Swift port created entirely by **[Aryamirsepasi](https://github.com/Aryamirsepasi)**! This was a big endeavour and they've done an amazing job. We're grateful to have them as a contributor. 🫡 +**1. [Joaov41](https://github.com/Joaov41):** + +Developed the amazing picture processing functionality for WritingTools, allowing the app to now work with images in addition to text! + ## 🤝 Contributing I welcome contributions! :D diff --git a/macOS/Latest_Version_for_Update_Check.txt b/macOS/Latest_Version_for_Update_Check.txt index 56a6051..d8263ee 100644 --- a/macOS/Latest_Version_for_Update_Check.txt +++ b/macOS/Latest_Version_for_Update_Check.txt @@ -1 +1 @@ -1 \ No newline at end of file +2 \ No newline at end of file diff --git a/macOS/README.md b/macOS/README.md index 5208aff..91abd39 100644 --- a/macOS/README.md +++ b/macOS/README.md @@ -76,6 +76,10 @@ The macOS port is being developed by **Aryamirsepasi**. GitHub: [https://github.com/Aryamirsepasi](https://github.com/Aryamirsepasi) +The amazing picture processing functionality was created by **Joaov41** +GitHub: [https://github.com/Joaov41](https://github.com/Joaov41) + Special Thanks to @sindresorhus for developing an amazing and stable keyboard shortcuts package for Swift. -GitHub: [https://github.com/sindresorhus/KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) \ No newline at end of file +GitHub: [https://github.com/sindresorhus/KeyboardShortcuts](https://github.com/sindresorhus/KeyboardShortcuts) + diff --git a/macOS/writing-tools.xcodeproj/project.pbxproj b/macOS/writing-tools.xcodeproj/project.pbxproj index 23203d8..a99b512 100644 --- a/macOS/writing-tools.xcodeproj/project.pbxproj +++ b/macOS/writing-tools.xcodeproj/project.pbxproj @@ -423,7 +423,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.aryamirsepasi.writing-tools"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; @@ -458,7 +458,7 @@ "@executable_path/../Frameworks", ); MACOSX_DEPLOYMENT_TARGET = 14.0; - MARKETING_VERSION = 1.0; + MARKETING_VERSION = 2.0; PRODUCT_BUNDLE_IDENTIFIER = "com.aryamirsepasi.writing-tools"; PRODUCT_NAME = "$(TARGET_NAME)"; PROVISIONING_PROFILE_SPECIFIER = ""; diff --git a/macOS/writing-tools/AppDelegate.swift b/macOS/writing-tools/AppDelegate.swift index 62e6456..b4f0634 100644 --- a/macOS/writing-tools/AppDelegate.swift +++ b/macOS/writing-tools/AppDelegate.swift @@ -54,7 +54,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { self?.showOnboarding() } - self?.requestAccessibilityPermissions() } KeyboardShortcuts.onKeyUp(for: .showPopup) { [weak self] in @@ -135,23 +134,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { alert.runModal() } - // Checks and requests accessibility permissions needed for app functionality - private func requestAccessibilityPermissions() { - let trusted = AXIsProcessTrusted() - if !trusted { - let alert = NSAlert() - alert.messageText = "Accessibility Access Required" - alert.informativeText = "Writing Tools needs accessibility access to detect text selection and simulate keyboard shortcuts. Please grant access in System Settings > Privacy & Security > Accessibility." - alert.alertStyle = .warning - alert.addButton(withTitle: "Open System Settings") - alert.addButton(withTitle: "Later") - - if alert.runModal() == .alertFirstButtonReturn { - NSWorkspace.shared.open(URL(string: "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility")!) - } - } - } - // Shows the first-time setup/onboarding window private func showOnboarding() { let window = NSWindow( @@ -241,18 +223,38 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { DispatchQueue.main.async { [weak self] in guard let self = self else { return } - // Store the current frontmost application before showing popup - if let frontmostApp = NSWorkspace.shared.frontmostApplication { - self.appState.previousApplication = frontmostApp + if let currentFrontmostApp = NSWorkspace.shared.frontmostApplication { + self.appState.previousApplication = currentFrontmostApp } self.closePopupWindow() - let pasteboard = NSPasteboard.general - let oldContents = pasteboard.string(forType: .string) - pasteboard.clearContents() + let generalPasteboard = NSPasteboard.general + + // Get initial pasteboard content + let oldContents = generalPasteboard.string(forType: .string) - // Simulate copy command + // Prioritized image types (in order of preference) + let supportedImageTypes = [ + NSPasteboard.PasteboardType("public.png"), + NSPasteboard.PasteboardType("public.jpeg"), + NSPasteboard.PasteboardType("public.tiff"), + NSPasteboard.PasteboardType("com.compuserve.gif"), + NSPasteboard.PasteboardType("public.image") + ] + var foundImage: Data? = nil + + // Try to find the first available image in order of preference + for type in supportedImageTypes { + if let data = generalPasteboard.data(forType: type) { + foundImage = data + NSLog("Selected image type: \(type)") + break // Take only the first matching format + } + } + + // Clear and perform copy command + generalPasteboard.clearContents() let source = CGEventSource(stateID: .hidSystemState) let keyDown = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: true) let keyUp = CGEvent(keyboardEventSource: source, virtualKey: 0x08, keyDown: false) @@ -263,21 +265,26 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { [weak self] in guard let self = self else { return } - let selectedText = pasteboard.string(forType: .string) ?? "" + let selectedText = generalPasteboard.string(forType: .string) ?? "" + + // Update app state with found image if any + self.appState.selectedImages = foundImage.map { [$0] } ?? [] - pasteboard.clearContents() + generalPasteboard.clearContents() if let oldContents = oldContents { - pasteboard.setString(oldContents, forType: .string) + generalPasteboard.setString(oldContents, forType: .string) } - // Create window even if no text is selected let window = PopupWindow(appState: self.appState) window.delegate = self self.appState.selectedText = selectedText self.popupWindow = window - if selectedText.isEmpty { + // Set appropriate window size based on content + if !selectedText.isEmpty || !self.appState.selectedImages.isEmpty { + window.setContentSize(NSSize(width: 400, height: 400)) + } else { window.setContentSize(NSSize(width: 400, height: 100)) } @@ -298,6 +305,7 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { existingWindow.cleanup() existingWindow.close() + self.appState.selectedImages = [] self.popupWindow = nil } } @@ -324,54 +332,70 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { // Service handler for processing selected text @objc func handleSelectedText(_ pboard: NSPasteboard, userData: String, error: AutoreleasingUnsafeMutablePointer) { - let types: [NSPasteboard.PasteboardType] = [ - .string, - .rtf, - NSPasteboard.PasteboardType("public.plain-text") - ] - - guard let selectedText = types.lazy.compactMap({ pboard.string(forType: $0) }).first, - !selectedText.isEmpty else { - error.pointee = "No text was selected" as NSString - return - } - - // Store the current frontmost application if let frontmostApp = NSWorkspace.shared.frontmostApplication { appState.previousApplication = frontmostApp - } - - // Store the selected text - appState.selectedText = selectedText - - // Set service trigger flag - isServiceTriggered = true - - // Show the popup - DispatchQueue.main.async { [weak self] in - guard let self = self else { return } - let window = PopupWindow(appState: self.appState) - window.delegate = self + // Prioritized image types (in order of preference) + let supportedImageTypes = [ + NSPasteboard.PasteboardType("public.png"), + NSPasteboard.PasteboardType("public.jpeg"), + NSPasteboard.PasteboardType("public.tiff"), + NSPasteboard.PasteboardType("com.compuserve.gif"), + NSPasteboard.PasteboardType("public.image") + ] - self.closePopupWindow() - self.popupWindow = window + var foundImage: Data? = nil - // Configure window for service mode - window.level = .floating - window.collectionBehavior = [.moveToActiveSpace] + // Try to find the first available image in order of preference + for type in supportedImageTypes { + if let data = pboard.data(forType: type) { + foundImage = data + NSLog("Selected image type (Service): \(type)") + break // Take only the first matching format + } + } - window.positionNearMouse() - window.makeKeyAndOrderFront(nil) - window.orderFrontRegardless() + let textTypes: [NSPasteboard.PasteboardType] = [ + .string, + .rtf, + NSPasteboard.PasteboardType("public.plain-text") + ] - // Activate our app - NSApp.activate() + guard let selectedText = textTypes.lazy.compactMap({ pboard.string(forType: $0) }).first, + !selectedText.isEmpty else { + error.pointee = "No text was selected" as NSString + return + } + + appState.selectedText = selectedText + appState.selectedImages = foundImage.map { [$0] } ?? [] + isServiceTriggered = true - // Reset the service trigger flag after a delay - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { - self.isServiceTriggered = false + DispatchQueue.main.async { [weak self] in + guard let self = self else { return } + + let window = PopupWindow(appState: self.appState) + window.delegate = self + + self.closePopupWindow() + self.popupWindow = window + + window.level = .floating + window.collectionBehavior = [.moveToActiveSpace] + + window.positionNearMouse() + window.makeKeyAndOrderFront(nil) + window.orderFrontRegardless() + + NSApp.activate() + + DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + self.isServiceTriggered = false + } } + } else { + error.pointee = "Could not determine frontmost application" as NSString + return } } } @@ -395,7 +419,7 @@ extension AppDelegate { // Register services provider NSApp.servicesProvider = self - + // Register the service NSUpdateDynamicServices() } diff --git a/macOS/writing-tools/AppState.swift b/macOS/writing-tools/AppState.swift index 3361c0a..f18ba68 100644 --- a/macOS/writing-tools/AppState.swift +++ b/macOS/writing-tools/AppState.swift @@ -5,26 +5,32 @@ class AppState: ObservableObject { @Published var geminiProvider: GeminiProvider @Published var openAIProvider: OpenAIProvider + @Published var mistralProvider: MistralProvider @Published var customInstruction: String = "" @Published var selectedText: String = "" @Published var isPopupVisible: Bool = false @Published var isProcessing: Bool = false @Published var previousApplication: NSRunningApplication? - - // Derived from AppSettings - var currentProvider: String { - get { AppSettings.shared.currentProvider } - set { AppSettings.shared.currentProvider = newValue } - } + @Published var selectedImages: [Data] = [] // Store selected image data + + // Current provider with UI binding support + @Published private(set) var currentProvider: String var activeProvider: any AIProvider { - currentProvider == "openai" ? openAIProvider : geminiProvider + if currentProvider == "openai" { + return openAIProvider + } else if currentProvider == "gemini" { + return geminiProvider + } else { + return mistralProvider + } } private init() { // Read from AppSettings let asettings = AppSettings.shared + self.currentProvider = asettings.currentProvider // Initialize Gemini let geminiConfig = GeminiConfig(apiKey: asettings.geminiApiKey, @@ -41,7 +47,15 @@ class AppState: ObservableObject { ) self.openAIProvider = OpenAIProvider(config: openAIConfig) - if asettings.openAIApiKey.isEmpty && asettings.geminiApiKey.isEmpty { + // Initialize Mistral + let mistralConfig = MistralConfig( + apiKey: asettings.mistralApiKey, + baseURL: asettings.mistralBaseURL, + model: asettings.mistralModel + ) + self.mistralProvider = MistralProvider(config: mistralConfig) + + if asettings.openAIApiKey.isEmpty && asettings.geminiApiKey.isEmpty && asettings.mistralApiKey.isEmpty { print("Warning: No API keys configured.") } } @@ -70,8 +84,24 @@ class AppState: ObservableObject { openAIProvider = OpenAIProvider(config: config) } - // Switch AI provider + // Update provider and persist to settings func setCurrentProvider(_ provider: String) { + currentProvider = provider AppSettings.shared.currentProvider = provider + objectWillChange.send() // Explicitly notify observers } + + func saveMistralConfig(apiKey: String, baseURL: String, model: String) { + let asettings = AppSettings.shared + asettings.mistralApiKey = apiKey + asettings.mistralBaseURL = baseURL + asettings.mistralModel = model + + let config = MistralConfig( + apiKey: apiKey, + baseURL: baseURL, + model: model + ) + mistralProvider = MistralProvider(config: config) + } } diff --git a/macOS/writing-tools/Models/AIProvider.swift b/macOS/writing-tools/Models/AIProvider.swift index 34e458a..6b33539 100644 --- a/macOS/writing-tools/Models/AIProvider.swift +++ b/macOS/writing-tools/Models/AIProvider.swift @@ -5,8 +5,8 @@ protocol AIProvider: ObservableObject { // Indicates if provider is processing a request var isProcessing: Bool { get set } - // Process text with optional system prompt - func processText(systemPrompt: String?, userPrompt: String) async throws -> String + // Process text with optional system prompt and images + func processText(systemPrompt: String?, userPrompt: String, images: [Data]) async throws -> String // Cancel ongoing requests func cancel() diff --git a/macOS/writing-tools/Models/AppSettings.swift b/macOS/writing-tools/Models/AppSettings.swift index 9d31c42..22692df 100644 --- a/macOS/writing-tools/Models/AppSettings.swift +++ b/macOS/writing-tools/Models/AppSettings.swift @@ -5,7 +5,7 @@ class AppSettings: ObservableObject { static let shared = AppSettings() private let defaults = UserDefaults.standard - + // MARK: - Published Settings @Published var geminiApiKey: String { didSet { defaults.set(geminiApiKey, forKey: "gemini_api_key") } @@ -50,7 +50,7 @@ class AppSettings: ObservableObject { @Published var useGradientTheme: Bool { didSet { defaults.set(useGradientTheme, forKey: "use_gradient_theme") } } - + // MARK: - HotKey data @Published var hotKeyCode: Int { didSet { defaults.set(hotKeyCode, forKey: "hotKey_keyCode") } @@ -58,7 +58,19 @@ class AppSettings: ObservableObject { @Published var hotKeyModifiers: Int { didSet { defaults.set(hotKeyModifiers, forKey: "hotKey_modifiers") } } - + + @Published var mistralApiKey: String { + didSet { defaults.set(mistralApiKey, forKey: "mistral_api_key") } + } + + @Published var mistralBaseURL: String { + didSet { defaults.set(mistralBaseURL, forKey: "mistral_base_url") } + } + + @Published var mistralModel: String { + didSet { defaults.set(mistralModel, forKey: "mistral_model") } + } + // MARK: - Init private init() { let defaults = UserDefaults.standard @@ -74,11 +86,15 @@ class AppSettings: ObservableObject { self.openAIOrganization = defaults.string(forKey: "openai_organization") ?? nil self.openAIProject = defaults.string(forKey: "openai_project") ?? nil + self.mistralApiKey = defaults.string(forKey: "mistral_api_key") ?? "" + self.mistralBaseURL = defaults.string(forKey: "mistral_base_url") ?? MistralConfig.defaultBaseURL + self.mistralModel = defaults.string(forKey: "mistral_model") ?? MistralConfig.defaultModel + self.currentProvider = defaults.string(forKey: "current_provider") ?? "gemini" self.shortcutText = defaults.string(forKey: "shortcut") ?? "⌥ Space" self.hasCompletedOnboarding = defaults.bool(forKey: "has_completed_onboarding") self.useGradientTheme = defaults.bool(forKey: "use_gradient_theme") - + // HotKey self.hotKeyCode = defaults.integer(forKey: "hotKey_keyCode") self.hotKeyModifiers = defaults.integer(forKey: "hotKey_modifiers") diff --git a/macOS/writing-tools/Models/GeminiProvider.swift b/macOS/writing-tools/Models/GeminiProvider.swift index 0e25954..f829c04 100644 --- a/macOS/writing-tools/Models/GeminiProvider.swift +++ b/macOS/writing-tools/Models/GeminiProvider.swift @@ -29,7 +29,7 @@ class GeminiProvider: ObservableObject, AIProvider { self.config = config } - func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String) async throws -> String { + func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String, images: [Data] = []) async throws -> String { isProcessing = true defer { isProcessing = false } @@ -39,16 +39,31 @@ class GeminiProvider: ObservableObject, AIProvider { throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "API key is missing."]) } - guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(config.modelName):generateContent?key=\(config.apiKey)") else { + // Create parts array with text + var parts: [[String: Any]] = [] + parts.append(["text": finalPrompt]) + + // Add image parts if present + for imageData in images { + parts.append([ + "inline_data": [ + "mime_type": "image/jpeg", + "data": imageData.base64EncodedString() + ] + ]) + } + + // Always use gemini-2.0-flash-exp + let modelName = "gemini-2.0-flash-exp" + + guard let url = URL(string: "https://generativelanguage.googleapis.com/v1beta/models/\(modelName):generateContent?key=\(config.apiKey)") else { throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL."]) } let requestBody: [String: Any] = [ "contents": [ [ - "parts": [ - ["text": finalPrompt] - ] + "parts": parts ] ] ] @@ -60,11 +75,24 @@ class GeminiProvider: ObservableObject, AIProvider { let (data, response) = try await URLSession.shared.data(for: request) - guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { - throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Server returned an error."]) + guard let httpResponse = response as? HTTPURLResponse else { + throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid response type."]) + } + + if httpResponse.statusCode != 200 { + // Try to parse error details from response + if let json = try? JSONSerialization.jsonObject(with: data) as? [String: Any], + let error = json["error"] as? [String: Any], + let message = error["message"] as? String { + print("API Error: \(message)") + throw NSError(domain: "GeminiAPI", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: message]) + } + print("Response data: \(String(data: data, encoding: .utf8) ?? "no data")") + throw NSError(domain: "GeminiAPI", code: httpResponse.statusCode, userInfo: [NSLocalizedDescriptionKey: "Server returned error \(httpResponse.statusCode)"]) } guard let json = try JSONSerialization.jsonObject(with: data, options: []) as? [String: Any] else { + print("Failed to parse response: \(String(data: data, encoding: .utf8) ?? "no data")") throw NSError(domain: "GeminiAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse JSON response."]) } @@ -85,3 +113,4 @@ class GeminiProvider: ObservableObject, AIProvider { isProcessing = false } } + diff --git a/macOS/writing-tools/Models/MistralProvider.swift b/macOS/writing-tools/Models/MistralProvider.swift new file mode 100644 index 0000000..9353c76 --- /dev/null +++ b/macOS/writing-tools/Models/MistralProvider.swift @@ -0,0 +1,84 @@ +// MistralProvider.swift +import Foundation +struct MistralConfig: Codable { + var apiKey: String + var baseURL: String + var model: String + + static let defaultBaseURL = "https://api.mistral.ai/v1" + static let defaultModel = "mistral-small-latest" +} +enum MistralModel: String, CaseIterable { + case mistralSmall = "mistral-small-latest" + case mistralMedium = "mistral-medium-latest" + case mistralLarge = "mistral-large-latest" + + var displayName: String { + switch self { + case .mistralSmall: return "Mistral Small (Fast)" + case .mistralMedium: return "Mistral Medium (Balanced)" + case .mistralLarge: return "Mistral Large (Most Capable)" + } + } +} +class MistralProvider: ObservableObject, AIProvider { + @Published var isProcessing = false + private var config: MistralConfig + + init(config: MistralConfig) { + self.config = config + } + + func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String, images: [Data] = []) async throws -> String { + // Mistral's text completion endpoint doesn't support images, so we ignore the images parameter + + isProcessing = true + defer { isProcessing = false } + + guard !config.apiKey.isEmpty else { + throw NSError(domain: "MistralAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "API key is missing."]) + } + + let baseURL = config.baseURL.isEmpty ? MistralConfig.defaultBaseURL : config.baseURL + guard let url = URL(string: "\(baseURL)/chat/completions") else { + throw NSError(domain: "MistralAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Invalid URL."]) + } + + var messages: [[String: Any]] = [] + if let systemPrompt = systemPrompt { + messages.append(["role": "system", "content": systemPrompt]) + } + messages.append(["role": "user", "content": userPrompt]) + + let requestBody: [String: Any] = [ + "model": config.model, + "messages": messages + ] + + var request = URLRequest(url: url) + request.httpMethod = "POST" + request.setValue("application/json", forHTTPHeaderField: "Content-Type") + request.setValue("Bearer \(config.apiKey)", forHTTPHeaderField: "Authorization") + request.httpBody = try JSONSerialization.data(withJSONObject: requestBody) + + let (data, response) = try await URLSession.shared.data(for: request) + + guard let httpResponse = response as? HTTPURLResponse, httpResponse.statusCode == 200 else { + throw NSError(domain: "MistralAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Server returned an error."]) + } + + guard let json = try JSONSerialization.jsonObject(with: data) as? [String: Any], + let choices = json["choices"] as? [[String: Any]], + let firstChoice = choices.first, + let message = firstChoice["message"] as? [String: Any], + let content = message["content"] as? String else { + throw NSError(domain: "MistralAPI", code: -1, userInfo: [NSLocalizedDescriptionKey: "Failed to parse response."]) + } + + return content + } + + func cancel() { + isProcessing = false + } +} diff --git a/macOS/writing-tools/Models/OpenAIProvider.swift b/macOS/writing-tools/Models/OpenAIProvider.swift index e9fb05e..7dccbb2 100644 --- a/macOS/writing-tools/Models/OpenAIProvider.swift +++ b/macOS/writing-tools/Models/OpenAIProvider.swift @@ -35,7 +35,9 @@ class OpenAIProvider: ObservableObject, AIProvider { self.config = config } - func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String) async throws -> String { + func processText(systemPrompt: String? = "You are a helpful writing assistant.", userPrompt: String, images: [Data] = []) async throws -> String { + // OpenAI's text completion endpoint doesn't support images, so we ignore the images parameter + isProcessing = true defer { isProcessing = false } diff --git a/macOS/writing-tools/UI/AboutView.swift b/macOS/writing-tools/UI/AboutView.swift index f23986e..2c40206 100644 --- a/macOS/writing-tools/UI/AboutView.swift +++ b/macOS/writing-tools/UI/AboutView.swift @@ -36,7 +36,7 @@ struct AboutView: View { Divider() - Text("Version: 1.0 (Based on Windows Port version 6.0)") + Text("Version: 2.0 (Based on Windows Port version 6.0)") .font(.caption) // Update checker section @@ -51,6 +51,10 @@ struct AboutView: View { Text("A new version is available!") .foregroundColor(.green) .font(.caption) + } else if !updateChecker.updateAvailable { + Text("The latest version is already installed!") + .foregroundColor(.green) + .font(.caption) } Button(action: { diff --git a/macOS/writing-tools/UI/OnboardingView.swift b/macOS/writing-tools/UI/OnboardingView.swift index 4eadf8e..7137383 100644 --- a/macOS/writing-tools/UI/OnboardingView.swift +++ b/macOS/writing-tools/UI/OnboardingView.swift @@ -7,7 +7,19 @@ struct OnboardingView: View { @State private var shortcutText = "⌃ Space" @State private var useGradientTheme = true @State private var selectedTheme = UserDefaults.standard.string(forKey: "theme_style") ?? "gradient" - @State private var isShowingSettings = false + + // Provider settings + @State private var selectedProvider = UserDefaults.standard.string(forKey: "current_provider") ?? "gemini" + @State private var geminiApiKey = UserDefaults.standard.string(forKey: "gemini_api_key") ?? "" + @State private var selectedGeminiModel = GeminiModel(rawValue: UserDefaults.standard.string(forKey: "gemini_model") ?? "gemini-1.5-flash-latest") ?? .oneflash + @State private var openAIApiKey = UserDefaults.standard.string(forKey: "openai_api_key") ?? "" + @State private var openAIBaseURL = UserDefaults.standard.string(forKey: "openai_base_url") ?? OpenAIConfig.defaultBaseURL + @State private var openAIOrganization = UserDefaults.standard.string(forKey: "openai_organization") ?? "" + @State private var openAIProject = UserDefaults.standard.string(forKey: "openai_project") ?? "" + @State private var openAIModelName = UserDefaults.standard.string(forKey: "openai_model") ?? OpenAIConfig.defaultModel + @State private var mistralApiKey = UserDefaults.standard.string(forKey: "mistral_api_key") ?? "" + @State private var mistralBaseURL = UserDefaults.standard.string(forKey: "mistral_base_url") ?? MistralConfig.defaultBaseURL + @State private var mistralModel = UserDefaults.standard.string(forKey: "mistral_model") ?? MistralConfig.defaultModel private let steps = [ OnboardingStep( @@ -22,31 +34,29 @@ struct OnboardingView: View { ), OnboardingStep( title: "Customize Your Experience", - description: "Set up your preferred shortcut and theme.", + description: "Set up your preferred shortcut, theme, and AI provider.", isPermissionStep: false ) ] var body: some View { VStack(spacing: 0) { - // Content area - VStack(spacing: 20) { - // Step content - switch currentStep { - case 0: - welcomeStep - case 1: - accessibilityStep - case 2: - customizationStep - default: - EmptyView() + ScrollView { + VStack(spacing: 20) { + switch currentStep { + case 0: + welcomeStep + case 1: + accessibilityStep + case 2: + customizationStep + default: + EmptyView() + } } - - Spacer(minLength: 0) + .padding(.horizontal) + .padding(.top, 20) } - .padding(.horizontal) - .padding(.top, 20) // Bottom navigation area VStack(spacing: 16) { @@ -74,7 +84,7 @@ struct OnboardingView: View { Button(currentStep == steps.count - 1 ? "Finish" : "Next") { if currentStep == steps.count - 1 { - saveSettingsAndContinue() + saveSettingsAndFinish() } else { withAnimation { currentStep += 1 @@ -87,10 +97,7 @@ struct OnboardingView: View { .padding() .background(Color(.windowBackgroundColor)) } - .frame(width: 500, height: 500) - .onAppear { - isShowingSettings = false - } + .frame(width: 600, height: 700) } private var welcomeStep: some View { @@ -142,38 +149,164 @@ struct OnboardingView: View { } private var customizationStep: some View { - VStack(spacing: 20) { + VStack(alignment: .leading, spacing: 20) { Text("Customize Your Experience") .font(.title) .bold() - VStack(alignment: .leading, spacing: 15) { - Text("Set your keyboard shortcut:") + // Shortcut and Theme + Group { + Text("Basic Settings") .font(.headline) - KeyboardShortcuts.Recorder("Shortcut:", name: .showPopup) - - Section("Appearance") { + VStack(alignment: .leading, spacing: 15) { + Text("Set your keyboard shortcut:") + KeyboardShortcuts.Recorder("Shortcut:", name: .showPopup) + + Divider() + + Text("Choose your theme:") Picker("Theme", selection: $selectedTheme) { Text("Standard").tag("standard") Text("Gradient").tag("gradient") Text("Glass").tag("glass") } .pickerStyle(.segmented) - .onChange(of: selectedTheme) { _, newValue in - UserDefaults.standard.set(newValue, forKey: "theme_style") - useGradientTheme = (newValue != "standard") + } + .padding(.horizontal) + } + + // AI Provider Selection + Group { + Text("AI Provider Settings") + .font(.headline) + + VStack(alignment: .leading, spacing: 15) { + Picker("Provider", selection: $selectedProvider) { + Text("Gemini AI").tag("gemini") + Text("OpenAI / Local LLM").tag("openai") + Text("Mistral AI").tag("mistral") + } + .pickerStyle(.segmented) + + // Provider-specific settings + if selectedProvider == "gemini" { + providerSettingsGemini + } else if selectedProvider == "mistral" { + providerSettingsMistral + } else { + providerSettingsOpenAI } } + .padding(.horizontal) + } + } + } + + private var providerSettingsGemini: some View { + VStack(alignment: .leading, spacing: 10) { + TextField("API Key", text: $geminiApiKey) + .textFieldStyle(.roundedBorder) + + Picker("Model", selection: $selectedGeminiModel) { + ForEach(GeminiModel.allCases, id: \.self) { model in + Text(model.displayName).tag(model) + } + } + + Button("Get API Key") { + NSWorkspace.shared.open(URL(string: "https://aistudio.google.com/app/apikey")!) + } + } + } + + private var providerSettingsMistral: some View { + VStack(alignment: .leading, spacing: 10) { + TextField("API Key", text: $mistralApiKey) + .textFieldStyle(.roundedBorder) + + TextField("Base URL", text: $mistralBaseURL) + .textFieldStyle(.roundedBorder) + + Picker("Model", selection: $mistralModel) { + ForEach(MistralModel.allCases, id: \.self) { model in + Text(model.displayName).tag(model.rawValue) + } + } + + Button("Get Mistral API Key") { + NSWorkspace.shared.open(URL(string: "https://console.mistral.ai/api-keys/")!) } } } + private var providerSettingsOpenAI: some View { + VStack(alignment: .leading, spacing: 10) { + TextField("API Key", text: $openAIApiKey) + .textFieldStyle(.roundedBorder) + + TextField("Base URL", text: $openAIBaseURL) + .textFieldStyle(.roundedBorder) + + TextField("Model Name", text: $openAIModelName) + .textFieldStyle(.roundedBorder) + + Text("OpenAI models include: gpt-4o, gpt-3.5-turbo, etc.") + .font(.caption) + .foregroundColor(.secondary) + + LinkText() + + TextField("Organization ID (Optional)", text: $openAIOrganization) + .textFieldStyle(.roundedBorder) + + TextField("Project ID (Optional)", text: $openAIProject) + .textFieldStyle(.roundedBorder) + + HStack { + Button("Get OpenAI API Key") { + NSWorkspace.shared.open(URL(string: "https://platform.openai.com/account/api-keys")!) + } + + Button("Ollama Documentation") { + NSWorkspace.shared.open(URL(string: "https://ollama.ai/download")!) + } + } + } + } - private func saveSettingsAndContinue() { + private func saveSettingsAndFinish() { + // Save theme settings UserDefaults.standard.set(selectedTheme, forKey: "theme_style") UserDefaults.standard.set(selectedTheme != "standard", forKey: "use_gradient_theme") - WindowManager.shared.transitonFromOnboardingToSettings(appState: appState) + + // Save provider-specific settings + if selectedProvider == "gemini" { + appState.saveGeminiConfig(apiKey: geminiApiKey, model: selectedGeminiModel) + } else if selectedProvider == "mistral" { + appState.saveMistralConfig( + apiKey: mistralApiKey, + baseURL: mistralBaseURL, + model: mistralModel + ) + } else { + appState.saveOpenAIConfig( + apiKey: openAIApiKey, + baseURL: openAIBaseURL, + organization: openAIOrganization, + project: openAIProject, + model: openAIModelName + ) + } + + // Set current provider + appState.setCurrentProvider(selectedProvider) + + // Mark onboarding as complete + UserDefaults.standard.set(true, forKey: "has_completed_onboarding") + + // Clean up windows + WindowManager.shared.cleanupWindows() } } @@ -182,3 +315,25 @@ struct OnboardingStep { let description: String let isPermissionStep: Bool } + +struct LinkText: View { + var body: some View { + HStack(spacing: 4) { + Text("Local LLMs: use the instructions on") + .font(.caption) + .foregroundColor(.secondary) + + Text("GitHub Page") + .font(.caption) + .foregroundColor(.blue) + .underline() + .onTapGesture { + NSWorkspace.shared.open(URL(string: "https://github.com/theJayTea/WritingTools?tab=readme-ov-file#-optional-ollama-local-llm-instructions")!) + } + + Text(".") + .font(.caption) + .foregroundColor(.secondary) + } + } +} diff --git a/macOS/writing-tools/UI/PopupView.swift b/macOS/writing-tools/UI/PopupView.swift index 1ebe49d..54e68ba 100644 --- a/macOS/writing-tools/UI/PopupView.swift +++ b/macOS/writing-tools/UI/PopupView.swift @@ -53,7 +53,7 @@ struct PopupView: View { } .padding(.horizontal) - if !appState.selectedText.isEmpty { + if !appState.selectedText.isEmpty || !appState.selectedImages.isEmpty { ScrollView { LazyVGrid(columns: [ GridItem(.flexible()), @@ -108,7 +108,8 @@ struct PopupView: View { do { let result = try await appState.activeProvider.processText( systemPrompt: command.prompt, - userPrompt: appState.selectedText + userPrompt: appState.selectedText, + images: appState.selectedImages ) if command.useResponseWindow { @@ -126,11 +127,12 @@ struct PopupView: View { window.orderFrontRegardless() } } else { - // Use inline replacement + // Set clipboard content and paste in one go NSPasteboard.general.clearContents() NSPasteboard.general.setString(result, forType: .string) - DispatchQueue.main.asyncAfter(deadline: .now() + 0.5) { + // Wait briefly then paste once + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { simulatePaste() } } @@ -163,7 +165,8 @@ struct PopupView: View { do { let result = try await appState.activeProvider.processText( systemPrompt: option.systemPrompt, - userPrompt: appState.selectedText + userPrompt: appState.selectedText, + images: appState.selectedImages ) if [.summary, .keyPoints, .table].contains(option) { @@ -173,20 +176,19 @@ struct PopupView: View { // Close the popup window after showing the response window closeAction() } else { + // Set clipboard content and paste in one go NSPasteboard.general.clearContents() NSPasteboard.general.setString(result, forType: .string) closeAction() // Reactivate previous application and paste - DispatchQueue.main.asyncAfter(deadline: .now() + 0.1) { - if let previousApp = appState.previousApplication { - previousApp.activate() - - // Wait for activation before pasting - DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { - simulatePaste() - } + if let previousApp = appState.previousApplication { + previousApp.activate() + + // Wait briefly for activation then paste once + DispatchQueue.main.asyncAfter(deadline: .now() + 0.2) { + simulatePaste() } } } @@ -223,7 +225,8 @@ struct PopupView: View { let result = try await appState.activeProvider.processText( systemPrompt: systemPrompt, - userPrompt: userPrompt + userPrompt: userPrompt, + images: appState.selectedImages ) // Always show response in a new window diff --git a/macOS/writing-tools/UI/PopupWindow.swift b/macOS/writing-tools/UI/PopupWindow.swift index 81df9ae..4e85b5a 100644 --- a/macOS/writing-tools/UI/PopupWindow.swift +++ b/macOS/writing-tools/UI/PopupWindow.swift @@ -69,10 +69,11 @@ class PopupWindow: NSWindow { let numBuiltInOptions = WritingOption.allCases.count let numCustomOptions = commandsManager.commands.count - let totalOptions = appState.selectedText.isEmpty ? 0 : (numBuiltInOptions + numCustomOptions) + let hasContent = !appState.selectedText.isEmpty || !appState.selectedImages.isEmpty + let totalOptions = hasContent ? (numBuiltInOptions + numCustomOptions) : 0 let numRows = ceil(Double(totalOptions) / 2.0) // 2 columns - let contentHeight = appState.selectedText.isEmpty ? + let contentHeight = !hasContent ? baseHeight : baseHeight + (buttonHeight * CGFloat(numRows)) + spacing + padding diff --git a/macOS/writing-tools/UI/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index 94f52a3..ba60356 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -220,7 +220,8 @@ final class ResponseViewModel: ObservableObject { If it's a request for help, provide clear guidance and examples where appropriate. Make sure tu use the language used or specified by the user instruction. Use Markdown formatting to make your response more readable. """, - userPrompt: contextualPrompt + userPrompt: contextualPrompt, + images: AppState.shared.selectedImages ) DispatchQueue.main.async { @@ -242,15 +243,17 @@ final class ResponseViewModel: ObservableObject { } func copyContent() { - // Only copy the latest AI response - if let latestAiMessage = messages.last(where: { $0.role == "assistant" }) { - NSPasteboard.general.clearContents() - NSPasteboard.general.setString(latestAiMessage.content, forType: .string) - - showCopyConfirmation = true - DispatchQueue.main.asyncAfter(deadline: .now() + 2) { - self.showCopyConfirmation = false - } + // Concatenate all messages in the conversation + let conversationText = messages.map { message in + return "\(message.role.capitalized): \(message.content)" // Format each message with role + }.joined(separator: "\n\n") // Join messages with double newlines for readability + + NSPasteboard.general.clearContents() + NSPasteboard.general.setString(conversationText, forType: .string) + + showCopyConfirmation = true + DispatchQueue.main.asyncAfter(deadline: .now() + 2) { + self.showCopyConfirmation = false } } -} + } diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index 9189cf2..680a16f 100644 --- a/macOS/writing-tools/UI/SettingsView.swift +++ b/macOS/writing-tools/UI/SettingsView.swift @@ -24,8 +24,14 @@ struct SettingsView: View { @State private var openAIProject = UserDefaults.standard.string(forKey: "openai_project") ?? "" @State private var openAIModelName = UserDefaults.standard.string(forKey: "openai_model") ?? OpenAIConfig.defaultModel - @State private var displayShortcut = "" + // Mistral settings + @State private var mistralApiKey = UserDefaults.standard.string(forKey: "mistral_api_key") ?? "" + @State private var mistralBaseURL = UserDefaults.standard.string(forKey: "mistral_base_url") ?? MistralConfig.defaultBaseURL + @State private var mistralModel = UserDefaults.standard.string(forKey: "mistral_model") ?? MistralConfig.defaultModel + + + @State private var displayShortcut = "" var showOnlyApiSetup: Bool = false @@ -80,6 +86,7 @@ struct SettingsView: View { Picker("Provider", selection: $selectedProvider) { Text("Gemini AI").tag("gemini") Text("OpenAI / Local LLM").tag("openai") + Text("Mistral AI").tag("mistral") } } } @@ -99,6 +106,24 @@ struct SettingsView: View { NSWorkspace.shared.open(URL(string: "https://aistudio.google.com/app/apikey")!) } } + } else if selectedProvider == "mistral" { + Section("Mistral AI Settings") { + TextField("API Key", text: $mistralApiKey) + .textFieldStyle(.roundedBorder) + + TextField("Base URL", text: $mistralBaseURL) + .textFieldStyle(.roundedBorder) + + Picker("Model", selection: $mistralModel) { + ForEach(MistralModel.allCases, id: \.self) { model in + Text(model.displayName).tag(model.rawValue) + } + } + + Button("Get Mistral API Key") { + NSWorkspace.shared.open(URL(string: "https://console.mistral.ai/api-keys/")!) + } + } } else { Section("OpenAI / Local LLM Settings") { TextField("API Key", text: $openAIApiKey) @@ -154,6 +179,12 @@ struct SettingsView: View { // Save provider-specific settings if selectedProvider == "gemini" { appState.saveGeminiConfig(apiKey: geminiApiKey, model: selectedGeminiModel) + } else if selectedProvider == "mistral" { + appState.saveMistralConfig( + apiKey: mistralApiKey, + baseURL: mistralBaseURL, + model: mistralModel + ) } else { appState.saveOpenAIConfig( apiKey: openAIApiKey, @@ -188,82 +219,4 @@ struct SettingsView: View { } } } - // Converts stored Carbon modifier bits into SwiftUI’s `NSEvent.ModifierFlags`. - private func decodeCarbonModifiers(_ rawModifiers: Int) -> NSEvent.ModifierFlags { - var flags = NSEvent.ModifierFlags() - let carbonFlags = UInt32(rawModifiers) - - if (carbonFlags & UInt32(cmdKey)) != 0 { flags.insert(.command) } - if (carbonFlags & UInt32(optionKey)) != 0 { flags.insert(.option) } - if (carbonFlags & UInt32(controlKey)) != 0 { flags.insert(.control) } - if (carbonFlags & UInt32(shiftKey)) != 0 { flags.insert(.shift) } - - return flags - } - - // Returns a human-friendly string like "⌘ =" or "⌃ D". - private func describeShortcut(keyCode: UInt16, flags: NSEvent.ModifierFlags) -> String { - var parts: [String] = [] - - if flags.contains(.command) { parts.append("⌘") } - if flags.contains(.option) { parts.append("⌥") } - if flags.contains(.control) { parts.append("⌃") } - if flags.contains(.shift) { parts.append("⇧") } - - let keyCodeInt = Int(keyCode) - - switch keyCodeInt { - case kVK_Space: - parts.append("Space") - case kVK_Return: - parts.append("Return") - case kVK_ANSI_Equal: - parts.append("=") - case kVK_ANSI_Minus: - parts.append("-") - case kVK_ANSI_LeftBracket: - parts.append("[") - case kVK_ANSI_RightBracket: - parts.append("]") - - default: - if let letter = keyCodeToLetter[keyCodeInt] { - parts.append(letter) - } else { - parts.append("(\(keyCode))") - } - } - - return parts.joined(separator: " ") - } - - // Maps the Carbon virtual key code (e.g. kVK_ANSI_D = 0x02) to the actual letter "D". - private let keyCodeToLetter: [Int: String] = [ - kVK_ANSI_A: "A", - kVK_ANSI_B: "B", - kVK_ANSI_C: "C", - kVK_ANSI_D: "D", - kVK_ANSI_E: "E", - kVK_ANSI_F: "F", - kVK_ANSI_G: "G", - kVK_ANSI_H: "H", - kVK_ANSI_I: "I", - kVK_ANSI_J: "J", - kVK_ANSI_K: "K", - kVK_ANSI_L: "L", - kVK_ANSI_M: "M", - kVK_ANSI_N: "N", - kVK_ANSI_O: "O", - kVK_ANSI_P: "P", - kVK_ANSI_Q: "Q", - kVK_ANSI_R: "R", - kVK_ANSI_S: "S", - kVK_ANSI_T: "T", - kVK_ANSI_U: "U", - kVK_ANSI_V: "V", - kVK_ANSI_W: "W", - kVK_ANSI_X: "X", - kVK_ANSI_Y: "Y", - kVK_ANSI_Z: "Z" - ] } diff --git a/macOS/writing-tools/UpdateChecker.swift b/macOS/writing-tools/UpdateChecker.swift index a65c20d..f04745e 100644 --- a/macOS/writing-tools/UpdateChecker.swift +++ b/macOS/writing-tools/UpdateChecker.swift @@ -2,9 +2,9 @@ import Foundation import AppKit @Observable -final class UpdateChecker: Sendable { +final class UpdateChecker { static let shared = UpdateChecker() - private let currentVersion = 1 // Current app version + private let currentVersion = 2 // Current app version private let updateCheckURL = "https://raw.githubusercontent.com/theJayTea/WritingTools/main/macOS/Latest_Version_for_Update_Check.txt" private let updateDownloadURL = "https://github.com/theJayTea/WritingTools/releases"