From 1370e3a1bd0ac928f124d00721ba24d94995b997 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Tue, 21 Jan 2025 12:48:37 +0100 Subject: [PATCH 1/3] added direct mistral support, first localization parts, fixed onboarding --- macOS/Latest_Version_for_Update_Check.txt | 2 +- macOS/writing-tools.xcodeproj/project.pbxproj | 5 + macOS/writing-tools/AppDelegate.swift | 2 - macOS/writing-tools/AppState.swift | 25 +- macOS/writing-tools/Localizable.xcstrings | 573 ++++++++++++++++++ macOS/writing-tools/Models/AppSettings.swift | 24 +- .../Models/MistralProvider.swift | 86 +++ macOS/writing-tools/UI/OnboardingView.swift | 223 +++++-- macOS/writing-tools/UI/ResponseView.swift | 2 +- macOS/writing-tools/UI/SettingsView.swift | 111 +--- macOS/writing-tools/UpdateChecker.swift | 8 +- 11 files changed, 935 insertions(+), 126 deletions(-) create mode 100644 macOS/writing-tools/Localizable.xcstrings create mode 100644 macOS/writing-tools/Models/MistralProvider.swift diff --git a/macOS/Latest_Version_for_Update_Check.txt b/macOS/Latest_Version_for_Update_Check.txt index 56a6051..b123147 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 +1.1 \ No newline at end of file diff --git a/macOS/writing-tools.xcodeproj/project.pbxproj b/macOS/writing-tools.xcodeproj/project.pbxproj index 23203d8..c99192e 100644 --- a/macOS/writing-tools.xcodeproj/project.pbxproj +++ b/macOS/writing-tools.xcodeproj/project.pbxproj @@ -196,6 +196,9 @@ knownRegions = ( en, Base, + de, + es, + fr, ); mainGroup = 2ABCBC152CDEB606001E4B5E; minimizedProjectReferenceProxies = 1; @@ -337,6 +340,7 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; + SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -394,6 +398,7 @@ MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; + SWIFT_EMIT_LOC_STRINGS = YES; }; name = Release; }; diff --git a/macOS/writing-tools/AppDelegate.swift b/macOS/writing-tools/AppDelegate.swift index 62e6456..26e3b30 100644 --- a/macOS/writing-tools/AppDelegate.swift +++ b/macOS/writing-tools/AppDelegate.swift @@ -53,8 +53,6 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { if !UserDefaults.standard.bool(forKey: "has_completed_onboarding") { self?.showOnboarding() } - - self?.requestAccessibilityPermissions() } KeyboardShortcuts.onKeyUp(for: .showPopup) { [weak self] in diff --git a/macOS/writing-tools/AppState.swift b/macOS/writing-tools/AppState.swift index 3361c0a..8b30785 100644 --- a/macOS/writing-tools/AppState.swift +++ b/macOS/writing-tools/AppState.swift @@ -5,13 +5,14 @@ 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 } @@ -44,6 +45,14 @@ class AppState: ObservableObject { if asettings.openAIApiKey.isEmpty && asettings.geminiApiKey.isEmpty { print("Warning: No API keys configured.") } + + // Initialize Mistral + let mistralConfig = MistralConfig( + apiKey: asettings.mistralApiKey, + baseURL: asettings.mistralBaseURL, + model: asettings.mistralModel + ) + self.mistralProvider = MistralProvider(config: mistralConfig) } // For Gemini changes @@ -74,4 +83,18 @@ class AppState: ObservableObject { func setCurrentProvider(_ provider: String) { AppSettings.shared.currentProvider = provider } + + 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/Localizable.xcstrings b/macOS/writing-tools/Localizable.xcstrings new file mode 100644 index 0000000..f2ae87f --- /dev/null +++ b/macOS/writing-tools/Localizable.xcstrings @@ -0,0 +1,573 @@ +{ + "sourceLanguage" : "en", + "strings" : { + "." : { + + }, + "• Helps you write with clarity and confidence" : { + + }, + "• Improves your writing with AI" : { + + }, + "• Support Custom Commands for anything you want" : { + + }, + "• Works in any application" : { + + }, + "1. Click the button below to open System Settings" : { + + }, + "2. Click the '+' button in the accessibility section" : { + + }, + "3. Navigate to Applications and select writing-tools" : { + + }, + "4. Enable the checkbox next to writing-tools" : { + + }, + "A new version is available!" : { + + }, + "About Writing Tools" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Über Writing Tools" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Acerca de Writing Tools" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "À propos de Writing Tools" + } + } + } + }, + "Add Custom Command" : { + + }, + "AI Provider" : { + + }, + "AI Provider Settings" : { + + }, + "API Key" : { + + }, + "Appearance" : { + + }, + "Ask a follow-up question..." : { + + }, + "Back" : { + + }, + "Base URL" : { + + }, + "Basic Settings" : { + + }, + "Cancel" : { + + }, + "Change Icon" : { + + }, + "Check for Updates" : { + + }, + "Check out Bliss AI on Google Play" : { + + }, + "Checking for updates..." : { + + }, + "Choose your theme:" : { + + }, + "Command Name" : { + + }, + "Complete Setup" : { + + }, + "Concise" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Prägnant" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Conciso" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Concis" + } + } + } + }, + "Copied!" : { + + }, + "Copy" : { + + }, + "Created with care by Jesai, a high school student." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Mit Sorgfalt erstellt von Jesai, einem Schüler." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Creado con cuidado por Jesai, un estudiante de secundaria." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Créé avec soin par Jesai, un lycéen." + } + } + } + }, + "Custom Commands" : { + + }, + "Customize Your Experience" : { + + }, + "Describe your change..." : { + + }, + "Download Update" : { + + }, + "Edit Command" : { + + }, + "Email: developer@aryamirsepasi.com" : { + + }, + "Email: jesaitarun@gmail.com" : { + + }, + "Finish" : { + + }, + "Friendly" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Freundlich" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amigable" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Amical" + } + } + } + }, + "Gemini AI" : { + + }, + "Gemini AI Settings" : { + + }, + "General Settings" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Allgemeine Einstellungen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Ajustes Generales" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Paramètres Généraux" + } + } + } + }, + "Get API Key" : { + + }, + "Get Mistral API Key" : { + + }, + "Get OpenAI API Key" : { + + }, + "GitHub Page" : { + + }, + "Glass" : { + + }, + "Global Shortcut:" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Globaler Tastaturkurzbefehl:" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Atajo Global:" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Raccourci Global:" + } + } + } + }, + "Gradient" : { + + }, + "How to enable accessibility access:" : { + + }, + "Icon" : { + + }, + "Key Points" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Kernpunkte" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Puntos Clave" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Points Clés" + } + } + } + }, + "Let's get you set up with just a few quick steps." : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Lassen Sie uns mit wenigen Schritten beginnen." + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configuremos todo en unos pocos pasos." + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Configurons tout en quelques étapes rapides." + } + } + } + }, + "Local LLMs: use the instructions on" : { + + }, + "Mistral AI" : { + + }, + "Mistral AI Settings" : { + + }, + "Model" : { + + }, + "Model Name" : { + + }, + "Name" : { + + }, + "New Command" : { + + }, + "Next" : { + + }, + "Ollama Documentation" : { + + }, + "Open System Settings" : { + + }, + "OpenAI / Local LLM" : { + + }, + "OpenAI / Local LLM Settings" : { + + }, + "OpenAI models include: gpt-4o, gpt-3.5-turbo, etc." : { + + }, + "Organization ID (Optional)" : { + + }, + "Professional" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Professionell" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Profesional" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Professionnel" + } + } + } + }, + "Project ID (Optional)" : { + + }, + "Prompt" : { + + }, + "Proofread" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Korrekturlesen" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Revisar" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Relecture" + } + } + } + }, + "Provider" : { + + }, + "Rewrite" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Umschreiben" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Reescribir" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Réécrire" + } + } + } + }, + "Save" : { + + }, + "Select Icon" : { + + }, + "Set your keyboard shortcut:" : { + + }, + "Shortcut:" : { + + }, + "Show Response in Chat Window" : { + + }, + "Standard" : { + + }, + "Summary" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Zusammenfassung" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Resumen" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Résumé" + } + } + } + }, + "Table" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabelle" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tabla" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tableau" + } + } + } + }, + "The macOS version is created by Arya Mirsepasi" : { + + }, + "Theme" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Design" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "Tema" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Thème" + } + } + } + }, + "Version: 1.0 (Based on Windows Port version 6.0)" : { + + }, + "Welcome to WritingTools!" : { + "extractionState" : "manual", + "localizations" : { + "de" : { + "stringUnit" : { + "state" : "translated", + "value" : "Willkommen bei WritingTools!" + } + }, + "es" : { + "stringUnit" : { + "state" : "translated", + "value" : "¡Bienvenido a WritingTools!" + } + }, + "fr" : { + "stringUnit" : { + "state" : "translated", + "value" : "Bienvenue sur WritingTools!" + } + } + } + }, + "When enabled, responses will appear in a chat window instead of replacing the selected text." : { + + }, + "Writing Tools is a free & lightweight tool that helps you improve your writing with AI, similar to Apple's new Apple Intelligence feature." : { + + } + }, + "version" : "1.0" +} \ No newline at end of file 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/MistralProvider.swift b/macOS/writing-tools/Models/MistralProvider.swift new file mode 100644 index 0000000..6d60625 --- /dev/null +++ b/macOS/writing-tools/Models/MistralProvider.swift @@ -0,0 +1,86 @@ +// 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) async throws -> String { + 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/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/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index 94f52a3..548c98c 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -168,7 +168,7 @@ extension View { } // Update ResponseViewModel to handle chat messages -final class ResponseViewModel: ObservableObject { +final class ResponseViewModel: ObservableObject, @unchecked Sendable { @Published var messages: [ChatMessage] = [] @Published var fontSize: CGFloat = 14 @Published var showCopyConfirmation: Bool = 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..3ff38a5 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 = 1.1 // 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" @@ -36,7 +36,7 @@ final class UpdateChecker: Sendable { print("Raw version data: '\(rawString)'") } - // Clean up the version string more aggressively + // Clean up the version string let cleanedString = String(data: data, encoding: .utf8)? .components(separatedBy: .newlines) .first? @@ -46,7 +46,7 @@ final class UpdateChecker: Sendable { if let versionString = cleanedString, !versionString.isEmpty, - let latestVersion = Int(versionString) { + let latestVersion = Double(versionString) { print("Parsed version: \(latestVersion)") updateAvailable = latestVersion > currentVersion } else { From fa5bde953b939881853f09f04d8e83d8c23f5dcd Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Thu, 23 Jan 2025 18:15:40 +0100 Subject: [PATCH 2/3] Revert "added direct mistral support, first localization parts, fixed onboarding" This reverts commit 1370e3a1bd0ac928f124d00721ba24d94995b997. --- macOS/Latest_Version_for_Update_Check.txt | 2 +- macOS/writing-tools.xcodeproj/project.pbxproj | 5 - macOS/writing-tools/AppDelegate.swift | 2 + macOS/writing-tools/AppState.swift | 25 +- macOS/writing-tools/Localizable.xcstrings | 573 ------------------ macOS/writing-tools/Models/AppSettings.swift | 24 +- .../Models/MistralProvider.swift | 86 --- macOS/writing-tools/UI/OnboardingView.swift | 223 ++----- macOS/writing-tools/UI/ResponseView.swift | 2 +- macOS/writing-tools/UI/SettingsView.swift | 111 +++- macOS/writing-tools/UpdateChecker.swift | 8 +- 11 files changed, 126 insertions(+), 935 deletions(-) delete mode 100644 macOS/writing-tools/Localizable.xcstrings delete mode 100644 macOS/writing-tools/Models/MistralProvider.swift diff --git a/macOS/Latest_Version_for_Update_Check.txt b/macOS/Latest_Version_for_Update_Check.txt index b123147..56a6051 100644 --- a/macOS/Latest_Version_for_Update_Check.txt +++ b/macOS/Latest_Version_for_Update_Check.txt @@ -1 +1 @@ -1.1 \ No newline at end of file +1 \ No newline at end of file diff --git a/macOS/writing-tools.xcodeproj/project.pbxproj b/macOS/writing-tools.xcodeproj/project.pbxproj index c99192e..23203d8 100644 --- a/macOS/writing-tools.xcodeproj/project.pbxproj +++ b/macOS/writing-tools.xcodeproj/project.pbxproj @@ -196,9 +196,6 @@ knownRegions = ( en, Base, - de, - es, - fr, ); mainGroup = 2ABCBC152CDEB606001E4B5E; minimizedProjectReferenceProxies = 1; @@ -340,7 +337,6 @@ ONLY_ACTIVE_ARCH = YES; SDKROOT = macosx; SWIFT_ACTIVE_COMPILATION_CONDITIONS = "DEBUG $(inherited)"; - SWIFT_EMIT_LOC_STRINGS = YES; SWIFT_OPTIMIZATION_LEVEL = "-Onone"; }; name = Debug; @@ -398,7 +394,6 @@ MTL_FAST_MATH = YES; SDKROOT = macosx; SWIFT_COMPILATION_MODE = wholemodule; - SWIFT_EMIT_LOC_STRINGS = YES; }; name = Release; }; diff --git a/macOS/writing-tools/AppDelegate.swift b/macOS/writing-tools/AppDelegate.swift index 26e3b30..62e6456 100644 --- a/macOS/writing-tools/AppDelegate.swift +++ b/macOS/writing-tools/AppDelegate.swift @@ -53,6 +53,8 @@ class AppDelegate: NSObject, NSApplicationDelegate, NSWindowDelegate { if !UserDefaults.standard.bool(forKey: "has_completed_onboarding") { self?.showOnboarding() } + + self?.requestAccessibilityPermissions() } KeyboardShortcuts.onKeyUp(for: .showPopup) { [weak self] in diff --git a/macOS/writing-tools/AppState.swift b/macOS/writing-tools/AppState.swift index 8b30785..3361c0a 100644 --- a/macOS/writing-tools/AppState.swift +++ b/macOS/writing-tools/AppState.swift @@ -5,14 +5,13 @@ 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 } @@ -45,14 +44,6 @@ class AppState: ObservableObject { if asettings.openAIApiKey.isEmpty && asettings.geminiApiKey.isEmpty { print("Warning: No API keys configured.") } - - // Initialize Mistral - let mistralConfig = MistralConfig( - apiKey: asettings.mistralApiKey, - baseURL: asettings.mistralBaseURL, - model: asettings.mistralModel - ) - self.mistralProvider = MistralProvider(config: mistralConfig) } // For Gemini changes @@ -83,18 +74,4 @@ class AppState: ObservableObject { func setCurrentProvider(_ provider: String) { AppSettings.shared.currentProvider = provider } - - 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/Localizable.xcstrings b/macOS/writing-tools/Localizable.xcstrings deleted file mode 100644 index f2ae87f..0000000 --- a/macOS/writing-tools/Localizable.xcstrings +++ /dev/null @@ -1,573 +0,0 @@ -{ - "sourceLanguage" : "en", - "strings" : { - "." : { - - }, - "• Helps you write with clarity and confidence" : { - - }, - "• Improves your writing with AI" : { - - }, - "• Support Custom Commands for anything you want" : { - - }, - "• Works in any application" : { - - }, - "1. Click the button below to open System Settings" : { - - }, - "2. Click the '+' button in the accessibility section" : { - - }, - "3. Navigate to Applications and select writing-tools" : { - - }, - "4. Enable the checkbox next to writing-tools" : { - - }, - "A new version is available!" : { - - }, - "About Writing Tools" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Über Writing Tools" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Acerca de Writing Tools" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "À propos de Writing Tools" - } - } - } - }, - "Add Custom Command" : { - - }, - "AI Provider" : { - - }, - "AI Provider Settings" : { - - }, - "API Key" : { - - }, - "Appearance" : { - - }, - "Ask a follow-up question..." : { - - }, - "Back" : { - - }, - "Base URL" : { - - }, - "Basic Settings" : { - - }, - "Cancel" : { - - }, - "Change Icon" : { - - }, - "Check for Updates" : { - - }, - "Check out Bliss AI on Google Play" : { - - }, - "Checking for updates..." : { - - }, - "Choose your theme:" : { - - }, - "Command Name" : { - - }, - "Complete Setup" : { - - }, - "Concise" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Prägnant" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Conciso" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Concis" - } - } - } - }, - "Copied!" : { - - }, - "Copy" : { - - }, - "Created with care by Jesai, a high school student." : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Mit Sorgfalt erstellt von Jesai, einem Schüler." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Creado con cuidado por Jesai, un estudiante de secundaria." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Créé avec soin par Jesai, un lycéen." - } - } - } - }, - "Custom Commands" : { - - }, - "Customize Your Experience" : { - - }, - "Describe your change..." : { - - }, - "Download Update" : { - - }, - "Edit Command" : { - - }, - "Email: developer@aryamirsepasi.com" : { - - }, - "Email: jesaitarun@gmail.com" : { - - }, - "Finish" : { - - }, - "Friendly" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Freundlich" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Amigable" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Amical" - } - } - } - }, - "Gemini AI" : { - - }, - "Gemini AI Settings" : { - - }, - "General Settings" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Allgemeine Einstellungen" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Ajustes Generales" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Paramètres Généraux" - } - } - } - }, - "Get API Key" : { - - }, - "Get Mistral API Key" : { - - }, - "Get OpenAI API Key" : { - - }, - "GitHub Page" : { - - }, - "Glass" : { - - }, - "Global Shortcut:" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Globaler Tastaturkurzbefehl:" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Atajo Global:" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Raccourci Global:" - } - } - } - }, - "Gradient" : { - - }, - "How to enable accessibility access:" : { - - }, - "Icon" : { - - }, - "Key Points" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Kernpunkte" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Puntos Clave" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Points Clés" - } - } - } - }, - "Let's get you set up with just a few quick steps." : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Lassen Sie uns mit wenigen Schritten beginnen." - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configuremos todo en unos pocos pasos." - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Configurons tout en quelques étapes rapides." - } - } - } - }, - "Local LLMs: use the instructions on" : { - - }, - "Mistral AI" : { - - }, - "Mistral AI Settings" : { - - }, - "Model" : { - - }, - "Model Name" : { - - }, - "Name" : { - - }, - "New Command" : { - - }, - "Next" : { - - }, - "Ollama Documentation" : { - - }, - "Open System Settings" : { - - }, - "OpenAI / Local LLM" : { - - }, - "OpenAI / Local LLM Settings" : { - - }, - "OpenAI models include: gpt-4o, gpt-3.5-turbo, etc." : { - - }, - "Organization ID (Optional)" : { - - }, - "Professional" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Professionell" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Profesional" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Professionnel" - } - } - } - }, - "Project ID (Optional)" : { - - }, - "Prompt" : { - - }, - "Proofread" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Korrekturlesen" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Revisar" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Relecture" - } - } - } - }, - "Provider" : { - - }, - "Rewrite" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Umschreiben" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Reescribir" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Réécrire" - } - } - } - }, - "Save" : { - - }, - "Select Icon" : { - - }, - "Set your keyboard shortcut:" : { - - }, - "Shortcut:" : { - - }, - "Show Response in Chat Window" : { - - }, - "Standard" : { - - }, - "Summary" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Zusammenfassung" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Resumen" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Résumé" - } - } - } - }, - "Table" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tabelle" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tabla" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tableau" - } - } - } - }, - "The macOS version is created by Arya Mirsepasi" : { - - }, - "Theme" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Design" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "Tema" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Thème" - } - } - } - }, - "Version: 1.0 (Based on Windows Port version 6.0)" : { - - }, - "Welcome to WritingTools!" : { - "extractionState" : "manual", - "localizations" : { - "de" : { - "stringUnit" : { - "state" : "translated", - "value" : "Willkommen bei WritingTools!" - } - }, - "es" : { - "stringUnit" : { - "state" : "translated", - "value" : "¡Bienvenido a WritingTools!" - } - }, - "fr" : { - "stringUnit" : { - "state" : "translated", - "value" : "Bienvenue sur WritingTools!" - } - } - } - }, - "When enabled, responses will appear in a chat window instead of replacing the selected text." : { - - }, - "Writing Tools is a free & lightweight tool that helps you improve your writing with AI, similar to Apple's new Apple Intelligence feature." : { - - } - }, - "version" : "1.0" -} \ No newline at end of file diff --git a/macOS/writing-tools/Models/AppSettings.swift b/macOS/writing-tools/Models/AppSettings.swift index 22692df..9d31c42 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,19 +58,7 @@ 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 @@ -86,15 +74,11 @@ 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/MistralProvider.swift b/macOS/writing-tools/Models/MistralProvider.swift deleted file mode 100644 index 6d60625..0000000 --- a/macOS/writing-tools/Models/MistralProvider.swift +++ /dev/null @@ -1,86 +0,0 @@ -// 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) async throws -> String { - 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/UI/OnboardingView.swift b/macOS/writing-tools/UI/OnboardingView.swift index 7137383..4eadf8e 100644 --- a/macOS/writing-tools/UI/OnboardingView.swift +++ b/macOS/writing-tools/UI/OnboardingView.swift @@ -7,19 +7,7 @@ struct OnboardingView: View { @State private var shortcutText = "⌃ Space" @State private var useGradientTheme = true @State private var selectedTheme = UserDefaults.standard.string(forKey: "theme_style") ?? "gradient" - - // 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 + @State private var isShowingSettings = false private let steps = [ OnboardingStep( @@ -34,29 +22,31 @@ struct OnboardingView: View { ), OnboardingStep( title: "Customize Your Experience", - description: "Set up your preferred shortcut, theme, and AI provider.", + description: "Set up your preferred shortcut and theme.", isPermissionStep: false ) ] var body: some View { VStack(spacing: 0) { - ScrollView { - VStack(spacing: 20) { - switch currentStep { - case 0: - welcomeStep - case 1: - accessibilityStep - case 2: - customizationStep - default: - EmptyView() - } + // Content area + VStack(spacing: 20) { + // Step content + switch currentStep { + case 0: + welcomeStep + case 1: + accessibilityStep + case 2: + customizationStep + default: + EmptyView() } - .padding(.horizontal) - .padding(.top, 20) + + Spacer(minLength: 0) } + .padding(.horizontal) + .padding(.top, 20) // Bottom navigation area VStack(spacing: 16) { @@ -84,7 +74,7 @@ struct OnboardingView: View { Button(currentStep == steps.count - 1 ? "Finish" : "Next") { if currentStep == steps.count - 1 { - saveSettingsAndFinish() + saveSettingsAndContinue() } else { withAnimation { currentStep += 1 @@ -97,7 +87,10 @@ struct OnboardingView: View { .padding() .background(Color(.windowBackgroundColor)) } - .frame(width: 600, height: 700) + .frame(width: 500, height: 500) + .onAppear { + isShowingSettings = false + } } private var welcomeStep: some View { @@ -149,164 +142,38 @@ struct OnboardingView: View { } private var customizationStep: some View { - VStack(alignment: .leading, spacing: 20) { + VStack(spacing: 20) { Text("Customize Your Experience") .font(.title) .bold() - // Shortcut and Theme - Group { - Text("Basic Settings") + VStack(alignment: .leading, spacing: 15) { + Text("Set your keyboard shortcut:") .font(.headline) - VStack(alignment: .leading, spacing: 15) { - Text("Set your keyboard shortcut:") - KeyboardShortcuts.Recorder("Shortcut:", name: .showPopup) - - Divider() - - Text("Choose your theme:") + KeyboardShortcuts.Recorder("Shortcut:", name: .showPopup) + + Section("Appearance") { Picker("Theme", selection: $selectedTheme) { Text("Standard").tag("standard") Text("Gradient").tag("gradient") Text("Glass").tag("glass") } .pickerStyle(.segmented) - } - .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 + .onChange(of: selectedTheme) { _, newValue in + UserDefaults.standard.set(newValue, forKey: "theme_style") + useGradientTheme = (newValue != "standard") } } - .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 saveSettingsAndFinish() { - // Save theme settings + private func saveSettingsAndContinue() { UserDefaults.standard.set(selectedTheme, forKey: "theme_style") UserDefaults.standard.set(selectedTheme != "standard", forKey: "use_gradient_theme") - - // 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() + WindowManager.shared.transitonFromOnboardingToSettings(appState: appState) } } @@ -315,25 +182,3 @@ 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/ResponseView.swift b/macOS/writing-tools/UI/ResponseView.swift index 548c98c..94f52a3 100644 --- a/macOS/writing-tools/UI/ResponseView.swift +++ b/macOS/writing-tools/UI/ResponseView.swift @@ -168,7 +168,7 @@ extension View { } // Update ResponseViewModel to handle chat messages -final class ResponseViewModel: ObservableObject, @unchecked Sendable { +final class ResponseViewModel: ObservableObject { @Published var messages: [ChatMessage] = [] @Published var fontSize: CGFloat = 14 @Published var showCopyConfirmation: Bool = false diff --git a/macOS/writing-tools/UI/SettingsView.swift b/macOS/writing-tools/UI/SettingsView.swift index 680a16f..9189cf2 100644 --- a/macOS/writing-tools/UI/SettingsView.swift +++ b/macOS/writing-tools/UI/SettingsView.swift @@ -24,15 +24,9 @@ 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 - - // 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 struct LinkText: View { @@ -86,7 +80,6 @@ struct SettingsView: View { Picker("Provider", selection: $selectedProvider) { Text("Gemini AI").tag("gemini") Text("OpenAI / Local LLM").tag("openai") - Text("Mistral AI").tag("mistral") } } } @@ -106,24 +99,6 @@ 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) @@ -179,12 +154,6 @@ 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, @@ -219,4 +188,82 @@ 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 3ff38a5..a65c20d 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 { +final class UpdateChecker: Sendable { static let shared = UpdateChecker() - private let currentVersion = 1.1 // Current app version + private let currentVersion = 1 // 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" @@ -36,7 +36,7 @@ final class UpdateChecker { print("Raw version data: '\(rawString)'") } - // Clean up the version string + // Clean up the version string more aggressively let cleanedString = String(data: data, encoding: .utf8)? .components(separatedBy: .newlines) .first? @@ -46,7 +46,7 @@ final class UpdateChecker { if let versionString = cleanedString, !versionString.isEmpty, - let latestVersion = Double(versionString) { + let latestVersion = Int(versionString) { print("Parsed version: \(latestVersion)") updateAvailable = latestVersion > currentVersion } else { From 79d77cf1c87a38020829575689b0c2711743f410 Mon Sep 17 00:00:00 2001 From: Aryamirsepasi Date: Thu, 23 Jan 2025 20:38:15 +0100 Subject: [PATCH 3/3] added picture processing (Thanks to @Joaov41), added mistral, added improvements to onboarding --- README.md | 4 + macOS/Latest_Version_for_Update_Check.txt | 2 +- macOS/README.md | 6 +- macOS/writing-tools.xcodeproj/project.pbxproj | 4 +- macOS/writing-tools/AppDelegate.swift | 164 +++++++------ macOS/writing-tools/AppState.swift | 48 +++- macOS/writing-tools/Models/AIProvider.swift | 4 +- macOS/writing-tools/Models/AppSettings.swift | 24 +- .../writing-tools/Models/GeminiProvider.swift | 43 +++- .../Models/MistralProvider.swift | 84 +++++++ .../writing-tools/Models/OpenAIProvider.swift | 4 +- macOS/writing-tools/UI/AboutView.swift | 6 +- macOS/writing-tools/UI/OnboardingView.swift | 223 +++++++++++++++--- macOS/writing-tools/UI/PopupView.swift | 31 +-- macOS/writing-tools/UI/PopupWindow.swift | 5 +- macOS/writing-tools/UI/ResponseView.swift | 25 +- macOS/writing-tools/UI/SettingsView.swift | 111 +++------ macOS/writing-tools/UpdateChecker.swift | 4 +- 18 files changed, 552 insertions(+), 240 deletions(-) create mode 100644 macOS/writing-tools/Models/MistralProvider.swift 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"