├── .gitignore ├── .idea └── .idea.Aictionary │ └── .idea │ ├── .gitignore │ ├── avalonia.xml │ ├── encodings.xml │ ├── indexLayout.xml │ └── vcs.xml ├── .serena ├── .gitignore └── project.yml ├── Aictionary.sln ├── Aictionary ├── Aictionary.csproj ├── App.axaml ├── App.axaml.cs ├── Assets │ ├── AppIcon.icns │ └── avalonia-logo.ico ├── Behaviors │ └── WordJumpBehavior.cs ├── Converters │ └── FormNameConverter.cs ├── Helpers │ └── LocaleHelper.cs ├── Info.plist ├── Models │ ├── AppSettings.cs │ ├── Definition.cs │ ├── DictionaryDownloadSource.cs │ ├── QueryHistoryEntry.cs │ ├── WordComparison.cs │ ├── WordDefinition.cs │ └── WordForms.cs ├── Program.cs ├── Services │ ├── DictionaryDownloadService.cs │ ├── DictionaryResourceService.cs │ ├── DictionaryService.cs │ ├── HotkeyService.cs │ ├── IDictionaryDownloadService.cs │ ├── IDictionaryResourceService.cs │ ├── IDictionaryService.cs │ ├── IHotkeyService.cs │ ├── IOpenAIService.cs │ ├── IQueryHistoryService.cs │ ├── ISettingsService.cs │ ├── OpenAIService.cs │ ├── QueryHistoryService.cs │ ├── QuickQueryService.cs │ └── SettingsService.cs ├── ViewLocator.cs ├── ViewModels │ ├── DownloadProgressViewModel.cs │ ├── MainWindowViewModel.cs │ ├── SettingsViewModel.cs │ ├── StatisticsViewModel.cs │ └── ViewModelBase.cs ├── Views │ ├── DownloadProgressWindow.axaml │ ├── DownloadProgressWindow.axaml.cs │ ├── MainWindow.axaml │ ├── MainWindow.axaml.cs │ ├── SettingsWindow.axaml │ ├── SettingsWindow.axaml.cs │ ├── StatisticsWindow.axaml │ └── StatisticsWindow.axaml.cs └── app.manifest ├── README.md └── build ├── BUILD.md ├── Build.cs ├── build.csproj ├── build.ps1 └── build.sh /.gitignore: -------------------------------------------------------------------------------- 1 | bin/ 2 | obj/ 3 | /packages/ 4 | riderModule.iml 5 | /_ReSharper.Caches/ 6 | Aictionary.sln.DotSettings.user 7 | .DS_Store 8 | # NUKE Build 9 | artifacts/ 10 | .nuke/ 11 | build/bin/ 12 | build/obj/ 13 | .tmp -------------------------------------------------------------------------------- /.idea/.idea.Aictionary/.idea/.gitignore: -------------------------------------------------------------------------------- 1 | # Default ignored files 2 | /shelf/ 3 | /workspace.xml 4 | # Rider ignored files 5 | /contentModel.xml 6 | /modules.xml 7 | /.idea.Aictionary.iml 8 | /projectSettingsUpdater.xml 9 | # Editor-based HTTP Client requests 10 | /httpRequests/ 11 | # Datasource local storage ignored files 12 | /dataSources/ 13 | /dataSources.local.xml 14 | -------------------------------------------------------------------------------- /.idea/.idea.Aictionary/.idea/avalonia.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 12 | 13 | -------------------------------------------------------------------------------- /.idea/.idea.Aictionary/.idea/encodings.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /.idea/.idea.Aictionary/.idea/indexLayout.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /.idea/.idea.Aictionary/.idea/vcs.xml: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.serena/.gitignore: -------------------------------------------------------------------------------- 1 | /cache 2 | -------------------------------------------------------------------------------- /.serena/project.yml: -------------------------------------------------------------------------------- 1 | # language of the project (csharp, python, rust, java, typescript, go, cpp, or ruby) 2 | # * For C, use cpp 3 | # * For JavaScript, use typescript 4 | # Special requirements: 5 | # * csharp: Requires the presence of a .sln file in the project folder. 6 | language: csharp 7 | 8 | # the encoding used by text files in the project 9 | # For a list of possible encodings, see https://docs.python.org/3.11/library/codecs.html#standard-encodings 10 | encoding: "utf-8" 11 | 12 | # whether to use the project's gitignore file to ignore files 13 | # Added on 2025-04-07 14 | ignore_all_files_in_gitignore: true 15 | # list of additional paths to ignore 16 | # same syntax as gitignore, so you can use * and ** 17 | # Was previously called `ignored_dirs`, please update your config if you are using that. 18 | # Added (renamed) on 2025-04-07 19 | ignored_paths: [] 20 | 21 | # whether the project is in read-only mode 22 | # If set to true, all editing tools will be disabled and attempts to use them will result in an error 23 | # Added on 2025-04-18 24 | read_only: false 25 | 26 | # list of tool names to exclude. We recommend not excluding any tools, see the readme for more details. 27 | # Below is the complete list of tools for convenience. 28 | # To make sure you have the latest list of tools, and to view their descriptions, 29 | # execute `uv run scripts/print_tool_overview.py`. 30 | # 31 | # * `activate_project`: Activates a project by name. 32 | # * `check_onboarding_performed`: Checks whether project onboarding was already performed. 33 | # * `create_text_file`: Creates/overwrites a file in the project directory. 34 | # * `delete_lines`: Deletes a range of lines within a file. 35 | # * `delete_memory`: Deletes a memory from Serena's project-specific memory store. 36 | # * `execute_shell_command`: Executes a shell command. 37 | # * `find_referencing_code_snippets`: Finds code snippets in which the symbol at the given location is referenced. 38 | # * `find_referencing_symbols`: Finds symbols that reference the symbol at the given location (optionally filtered by type). 39 | # * `find_symbol`: Performs a global (or local) search for symbols with/containing a given name/substring (optionally filtered by type). 40 | # * `get_current_config`: Prints the current configuration of the agent, including the active and available projects, tools, contexts, and modes. 41 | # * `get_symbols_overview`: Gets an overview of the top-level symbols defined in a given file. 42 | # * `initial_instructions`: Gets the initial instructions for the current project. 43 | # Should only be used in settings where the system prompt cannot be set, 44 | # e.g. in clients you have no control over, like Claude Desktop. 45 | # * `insert_after_symbol`: Inserts content after the end of the definition of a given symbol. 46 | # * `insert_at_line`: Inserts content at a given line in a file. 47 | # * `insert_before_symbol`: Inserts content before the beginning of the definition of a given symbol. 48 | # * `list_dir`: Lists files and directories in the given directory (optionally with recursion). 49 | # * `list_memories`: Lists memories in Serena's project-specific memory store. 50 | # * `onboarding`: Performs onboarding (identifying the project structure and essential tasks, e.g. for testing or building). 51 | # * `prepare_for_new_conversation`: Provides instructions for preparing for a new conversation (in order to continue with the necessary context). 52 | # * `read_file`: Reads a file within the project directory. 53 | # * `read_memory`: Reads the memory with the given name from Serena's project-specific memory store. 54 | # * `remove_project`: Removes a project from the Serena configuration. 55 | # * `replace_lines`: Replaces a range of lines within a file with new content. 56 | # * `replace_symbol_body`: Replaces the full definition of a symbol. 57 | # * `restart_language_server`: Restarts the language server, may be necessary when edits not through Serena happen. 58 | # * `search_for_pattern`: Performs a search for a pattern in the project. 59 | # * `summarize_changes`: Provides instructions for summarizing the changes made to the codebase. 60 | # * `switch_modes`: Activates modes by providing a list of their names 61 | # * `think_about_collected_information`: Thinking tool for pondering the completeness of collected information. 62 | # * `think_about_task_adherence`: Thinking tool for determining whether the agent is still on track with the current task. 63 | # * `think_about_whether_you_are_done`: Thinking tool for determining whether the task is truly completed. 64 | # * `write_memory`: Writes a named memory (for future reference) to Serena's project-specific memory store. 65 | excluded_tools: [] 66 | 67 | # initial prompt for the project. It will always be given to the LLM upon activating the project 68 | # (contrary to the memories, which are loaded on demand). 69 | initial_prompt: "" 70 | 71 | project_name: "Aictionary" 72 | -------------------------------------------------------------------------------- /Aictionary.sln: -------------------------------------------------------------------------------- 1 |  2 | Microsoft Visual Studio Solution File, Format Version 12.00 3 | Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Aictionary", "Aictionary\Aictionary.csproj", "{F713BC49-89A0-492D-AF2D-4B3338BB2C66}" 4 | EndProject 5 | Global 6 | GlobalSection(SolutionConfigurationPlatforms) = preSolution 7 | Debug|Any CPU = Debug|Any CPU 8 | Release|Any CPU = Release|Any CPU 9 | EndGlobalSection 10 | GlobalSection(ProjectConfigurationPlatforms) = postSolution 11 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Debug|Any CPU.ActiveCfg = Debug|Any CPU 12 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Debug|Any CPU.Build.0 = Debug|Any CPU 13 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Release|Any CPU.ActiveCfg = Release|Any CPU 14 | {F713BC49-89A0-492D-AF2D-4B3338BB2C66}.Release|Any CPU.Build.0 = Release|Any CPU 15 | EndGlobalSection 16 | EndGlobal 17 | -------------------------------------------------------------------------------- /Aictionary/Aictionary.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | WinExe 4 | net8.0 5 | enable 6 | true 7 | app.manifest 8 | true 9 | true 10 | true 11 | false 12 | true 13 | bin\$(Configuration)\$(Platform)\Aictionary.app/Contents/MacOS 14 | false 15 | true 16 | 17 | 18 | 19 | 20 | 21 | 22 | 23 | 24 | 25 | 26 | 27 | 28 | 29 | 30 | 31 | 32 | 33 | 34 | 35 | 36 | 37 | 38 | 39 | 40 | 43 | 44 | None 45 | All 46 | 47 | 48 | 49 | 50 | 51 | 52 | 53 | 54 | 55 | 56 | 57 | 58 | 59 | DownloadProgressWindow.axaml 60 | 61 | 62 | 63 | -------------------------------------------------------------------------------- /Aictionary/App.axaml: -------------------------------------------------------------------------------- 1 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /Aictionary/App.axaml.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Avalonia; 3 | using Avalonia.Controls.ApplicationLifetimes; 4 | using Avalonia.Markup.Xaml; 5 | using Aictionary.Services; 6 | using Aictionary.ViewModels; 7 | using Aictionary.Views; 8 | 9 | namespace Aictionary; 10 | 11 | public partial class App : Application 12 | { 13 | private static ISettingsService? _settingsService; 14 | private static IDictionaryService? _dictionaryService; 15 | private static IOpenAIService? _openAIService; 16 | private static IQueryHistoryService? _queryHistoryService; 17 | private static IHotkeyService? _hotkeyService; 18 | private static QuickQueryService? _quickQueryService; 19 | 20 | public override void Initialize() 21 | { 22 | AvaloniaXamlLoader.Load(this); 23 | } 24 | 25 | public override async void OnFrameworkInitializationCompleted() 26 | { 27 | System.Console.WriteLine("[App] OnFrameworkInitializationCompleted started"); 28 | 29 | if (ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) 30 | { 31 | System.Console.WriteLine("[App] Desktop lifetime detected"); 32 | 33 | // Initialize settings service first to get dictionary path 34 | System.Console.WriteLine("[App] Initializing settings service..."); 35 | _settingsService = new SettingsService(); 36 | await _settingsService.LoadSettingsAsync(); 37 | 38 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath; 39 | System.Console.WriteLine($"[App] Dictionary path from settings: {dictionaryPath}"); 40 | 41 | // Ensure dictionary exists before initializing other services 42 | var resourceService = new DictionaryResourceService(); 43 | System.Console.WriteLine("[App] DictionaryResourceService created"); 44 | 45 | var downloadService = new DictionaryDownloadService(resourceService); 46 | System.Console.WriteLine("[App] DictionaryDownloadService created"); 47 | 48 | var dictionaryExists = downloadService.DictionaryExists(dictionaryPath); 49 | System.Console.WriteLine($"[App] Dictionary exists: {dictionaryExists}"); 50 | 51 | Views.DownloadProgressWindow? downloadWindow = null; 52 | var downloadSucceeded = true; 53 | 54 | if (!dictionaryExists) 55 | { 56 | System.Console.WriteLine("[App] Creating download window"); 57 | downloadWindow = new Views.DownloadProgressWindow(); 58 | 59 | System.Console.WriteLine("[App] Showing download window"); 60 | downloadWindow.Show(); 61 | 62 | try 63 | { 64 | System.Console.WriteLine("[App] Starting download..."); 65 | var downloadSource = _settingsService.CurrentSettings.DictionaryDownloadSource; 66 | System.Console.WriteLine($"[App] Using download source: {downloadSource}"); 67 | await downloadService.EnsureDictionaryExistsAsync(dictionaryPath, downloadSource, (message, progress) => 68 | { 69 | System.Console.WriteLine($"[App] Progress callback: {message} ({progress}%)"); 70 | Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => 71 | { 72 | System.Console.WriteLine($"[App] UI Thread updating: {message}"); 73 | downloadWindow.ViewModel.StatusMessage = message; 74 | downloadWindow.ViewModel.Progress = progress; 75 | downloadWindow.ViewModel.IsIndeterminate = progress < 10; 76 | 77 | if (progress >= 100) 78 | { 79 | System.Console.WriteLine("[App] Download completed!"); 80 | downloadWindow.ViewModel.IsCompleted = true; 81 | } 82 | }); 83 | }); 84 | System.Console.WriteLine("[App] Download finished successfully"); 85 | } 86 | catch (System.Exception ex) 87 | { 88 | downloadSucceeded = false; 89 | System.Console.WriteLine($"[App] Download ERROR: {ex.GetType().Name}: {ex.Message}"); 90 | System.Console.WriteLine($"[App] Stack trace: {ex.StackTrace}"); 91 | await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => 92 | { 93 | downloadWindow.ViewModel.StatusMessage = $"Error: {ex.Message}"; 94 | downloadWindow.ViewModel.IsCompleted = true; 95 | downloadWindow.ViewModel.IsIndeterminate = false; 96 | downloadWindow.ViewModel.Progress = 0; 97 | }); 98 | 99 | // Keep the error window open and wait for user to close it 100 | System.Console.WriteLine("[App] Download failed. Window will remain open for user to read error and close manually."); 101 | return; 102 | } 103 | } 104 | 105 | System.Console.WriteLine("[App] Initializing other services..."); 106 | 107 | _dictionaryService = new DictionaryService(_settingsService); 108 | _openAIService = new OpenAIService(_settingsService); 109 | _queryHistoryService = new QueryHistoryService(); 110 | _hotkeyService = new HotkeyService(); 111 | _quickQueryService = new QuickQueryService(_hotkeyService, _settingsService); 112 | 113 | System.Console.WriteLine("[App] Initializing quick query service..."); 114 | _quickQueryService.Initialize(); 115 | 116 | System.Console.WriteLine("[App] Creating main window"); 117 | desktop.MainWindow = new MainWindow 118 | { 119 | DataContext = new MainWindowViewModel(_dictionaryService, _openAIService, _settingsService, _queryHistoryService), 120 | }; 121 | System.Console.WriteLine("[App] Main window created"); 122 | 123 | // Show main window first, then close download window to prevent app exit 124 | if (downloadWindow != null) 125 | { 126 | System.Console.WriteLine("[App] Showing main window and closing download window"); 127 | await Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => 128 | { 129 | desktop.MainWindow.Show(); 130 | System.Console.WriteLine("[App] Main window shown, now closing download window"); 131 | downloadWindow.Close(); 132 | System.Console.WriteLine("[App] Download window closed"); 133 | }); 134 | } 135 | else 136 | { 137 | // No download window, just show main window normally 138 | desktop.MainWindow.Show(); 139 | } 140 | } 141 | 142 | base.OnFrameworkInitializationCompleted(); 143 | System.Console.WriteLine("[App] OnFrameworkInitializationCompleted finished"); 144 | } 145 | 146 | public static SettingsWindow CreateSettingsWindow() 147 | { 148 | if (_settingsService == null || _dictionaryService == null || _openAIService == null) 149 | { 150 | throw new System.InvalidOperationException("Services not initialized"); 151 | } 152 | 153 | return new SettingsWindow 154 | { 155 | DataContext = new SettingsViewModel(_settingsService, _dictionaryService, _openAIService, _hotkeyService, _quickQueryService) 156 | }; 157 | } 158 | 159 | public static StatisticsWindow CreateStatisticsWindow() 160 | { 161 | if (_queryHistoryService == null) 162 | { 163 | throw new System.InvalidOperationException("Services not initialized"); 164 | } 165 | 166 | return new StatisticsWindow 167 | { 168 | DataContext = new StatisticsViewModel(_queryHistoryService) 169 | }; 170 | } 171 | } 172 | -------------------------------------------------------------------------------- /Aictionary/Assets/AppIcon.icns: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahpxex/Aictionary/19fc436d2a92581b087d4ce5fe7677637d9dfda2/Aictionary/Assets/AppIcon.icns -------------------------------------------------------------------------------- /Aictionary/Assets/avalonia-logo.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/ahpxex/Aictionary/19fc436d2a92581b087d4ce5fe7677637d9dfda2/Aictionary/Assets/avalonia-logo.ico -------------------------------------------------------------------------------- /Aictionary/Behaviors/WordJumpBehavior.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.RegularExpressions; 3 | using Avalonia; 4 | using Avalonia.Controls; 5 | using Avalonia.Input; 6 | using Avalonia.Interactivity; 7 | using Avalonia.Media; 8 | using Aictionary.ViewModels; 9 | using Avalonia.VisualTree; 10 | 11 | namespace Aictionary.Behaviors; 12 | 13 | public static class WordJumpBehavior 14 | { 15 | public static readonly AttachedProperty IsEnabledProperty = 16 | AvaloniaProperty.RegisterAttached("IsEnabled", typeof(WordJumpBehavior)); 17 | 18 | public static bool GetIsEnabled(SelectableTextBlock element) 19 | { 20 | return element.GetValue(IsEnabledProperty); 21 | } 22 | 23 | public static void SetIsEnabled(SelectableTextBlock element, bool value) 24 | { 25 | element.SetValue(IsEnabledProperty, value); 26 | } 27 | 28 | static WordJumpBehavior() 29 | { 30 | IsEnabledProperty.Changed.AddClassHandler(OnIsEnabledChanged); 31 | } 32 | 33 | private static void OnIsEnabledChanged(SelectableTextBlock textBlock, AvaloniaPropertyChangedEventArgs e) 34 | { 35 | if (e.NewValue is true) 36 | { 37 | textBlock.PointerMoved += OnPointerMoved; 38 | textBlock.PointerExited += OnPointerExited; 39 | // Use AddHandler with handledEventsToo=true to receive events even if SelectableTextBlock handled them 40 | textBlock.AddHandler(InputElement.PointerPressedEvent, OnPointerPressed, handledEventsToo: true); 41 | } 42 | else 43 | { 44 | textBlock.PointerMoved -= OnPointerMoved; 45 | textBlock.PointerExited -= OnPointerExited; 46 | textBlock.RemoveHandler(InputElement.PointerPressedEvent, OnPointerPressed); 47 | } 48 | } 49 | 50 | private static bool _isModifierPressed = false; 51 | 52 | private static void OnPointerMoved(object? sender, PointerEventArgs e) 53 | { 54 | if (sender is not SelectableTextBlock textBlock) return; 55 | 56 | var keyModifiers = e.KeyModifiers; 57 | var isCmdOrCtrlPressed = keyModifiers.HasFlag(KeyModifiers.Meta) || keyModifiers.HasFlag(KeyModifiers.Control); 58 | 59 | if (isCmdOrCtrlPressed) 60 | { 61 | _isModifierPressed = true; 62 | textBlock.Cursor = new Cursor(StandardCursorType.Hand); 63 | textBlock.TextDecorations = TextDecorations.Underline; 64 | } 65 | else 66 | { 67 | _isModifierPressed = false; 68 | textBlock.Cursor = Cursor.Default; 69 | textBlock.TextDecorations = null; 70 | } 71 | } 72 | 73 | private static void OnPointerExited(object? sender, PointerEventArgs e) 74 | { 75 | if (sender is not SelectableTextBlock textBlock) return; 76 | 77 | textBlock.Cursor = Cursor.Default; 78 | textBlock.TextDecorations = null; 79 | } 80 | 81 | private static void OnPointerPressed(object? sender, PointerPressedEventArgs e) 82 | { 83 | if (sender is not SelectableTextBlock textBlock) return; 84 | 85 | var keyModifiers = e.KeyModifiers; 86 | var isCmdOrCtrlPressed = keyModifiers.HasFlag(KeyModifiers.Meta) || keyModifiers.HasFlag(KeyModifiers.Control); 87 | 88 | if (!isCmdOrCtrlPressed) return; 89 | 90 | var point = e.GetCurrentPoint(textBlock); 91 | if (!point.Properties.IsLeftButtonPressed) return; 92 | 93 | // Get the word at the click position 94 | var word = GetWordAtPosition(textBlock); 95 | if (string.IsNullOrWhiteSpace(word)) return; 96 | 97 | // Only jump if it's an English word (contains only ASCII letters) 98 | if (!IsEnglishWord(word)) return; 99 | 100 | // Find the MainWindow and trigger search 101 | var window = GetParentWindow(textBlock); 102 | if (window?.DataContext is MainWindowViewModel viewModel) 103 | { 104 | viewModel.SearchText = word; 105 | viewModel.SearchCommand.Execute().Subscribe(); 106 | } 107 | 108 | e.Handled = true; 109 | } 110 | 111 | private static string? GetWordAtPosition(SelectableTextBlock textBlock) 112 | { 113 | // If there's a selection, use the selected text 114 | var selectedText = textBlock.SelectedText; 115 | if (!string.IsNullOrWhiteSpace(selectedText)) 116 | { 117 | // Extract the first English word from selection 118 | var words = Regex.Matches(selectedText, @"\b[a-zA-Z]+\b"); 119 | if (words.Count > 0) 120 | { 121 | return words[0].Value; 122 | } 123 | } 124 | 125 | // Otherwise, use the entire text (for comparison words) 126 | var text = textBlock.Text; 127 | if (string.IsNullOrWhiteSpace(text)) return null; 128 | 129 | // If the entire text is a single English word, return it 130 | text = text.Trim(); 131 | if (IsEnglishWord(text)) 132 | { 133 | return text; 134 | } 135 | 136 | // Otherwise, extract the first English word from the text 137 | var wordMatches = Regex.Matches(text, @"\b[a-zA-Z]+\b"); 138 | if (wordMatches.Count > 0) 139 | { 140 | return wordMatches[0].Value; 141 | } 142 | 143 | return null; 144 | } 145 | 146 | private static bool IsEnglishWord(string word) 147 | { 148 | // Check if the word contains only ASCII letters 149 | return Regex.IsMatch(word, @"^[a-zA-Z]+$"); 150 | } 151 | 152 | private static Window? GetParentWindow(Visual? visual) 153 | { 154 | while (visual != null) 155 | { 156 | if (visual is Window window) 157 | return window; 158 | visual = visual.GetVisualParent(); 159 | } 160 | return null; 161 | } 162 | } 163 | -------------------------------------------------------------------------------- /Aictionary/Converters/FormNameConverter.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Avalonia.Data.Converters; 4 | 5 | namespace Aictionary.Converters; 6 | 7 | public class FormNameConverter : IValueConverter 8 | { 9 | public object? Convert(object? value, Type targetType, object? parameter, CultureInfo culture) 10 | { 11 | if (value is not string formName) 12 | return value; 13 | 14 | // Convert snake_case to Title Case 15 | // e.g., "third_person_singular" -> "Third Person Singular" 16 | var words = formName.Split('_'); 17 | for (int i = 0; i < words.Length; i++) 18 | { 19 | if (words[i].Length > 0) 20 | { 21 | words[i] = char.ToUpper(words[i][0]) + words[i].Substring(1); 22 | } 23 | } 24 | return string.Join(" ", words); 25 | } 26 | 27 | public object? ConvertBack(object? value, Type targetType, object? parameter, CultureInfo culture) 28 | { 29 | // ConvertBack is not needed for one-way display bindings 30 | return value; 31 | } 32 | } 33 | -------------------------------------------------------------------------------- /Aictionary/Helpers/LocaleHelper.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Globalization; 3 | using Aictionary.Models; 4 | 5 | namespace Aictionary.Helpers; 6 | 7 | public static class LocaleHelper 8 | { 9 | public static DictionaryDownloadSource GetDefaultDownloadSource() 10 | { 11 | try 12 | { 13 | var currentCulture = CultureInfo.CurrentCulture; 14 | var timeZone = TimeZoneInfo.Local; 15 | 16 | // Check if system is in Simplified Chinese and UTC+8 17 | var isSimplifiedChinese = currentCulture.Name.StartsWith("zh-CN", StringComparison.OrdinalIgnoreCase) || 18 | currentCulture.Name.Equals("zh-Hans", StringComparison.OrdinalIgnoreCase); 19 | 20 | var isUtcPlus8 = timeZone.BaseUtcOffset.TotalHours == 8; 21 | 22 | if (isSimplifiedChinese && isUtcPlus8) 23 | { 24 | return DictionaryDownloadSource.Gitee; 25 | } 26 | } 27 | catch (Exception ex) 28 | { 29 | Console.WriteLine($"[LocaleHelper] Error detecting locale: {ex.Message}"); 30 | } 31 | 32 | return DictionaryDownloadSource.GitHub; 33 | } 34 | } 35 | -------------------------------------------------------------------------------- /Aictionary/Info.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | CFBundleName 6 | Aictionary 7 | CFBundleDisplayName 8 | Aictionary 9 | CFBundleIdentifier 10 | com.ahpx.aictionary 11 | CFBundleVersion 12 | 1.0.0 13 | CFBundleShortVersionString 14 | 1.0.0 15 | CFBundleExecutable 16 | Aictionary 17 | CFBundlePackageType 18 | APPL 19 | CFBundleSignature 20 | ???? 21 | CFBundleSupportedPlatforms 22 | 23 | MacOSX 24 | 25 | LSMinimumSystemVersion 26 | 11.0 27 | NSPrincipalClass 28 | NSApplication 29 | NSHighResolutionCapable 30 | 31 | CFBundleIconFile 32 | AppIcon.icns 33 | 34 | 35 | -------------------------------------------------------------------------------- /Aictionary/Models/AppSettings.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | using Aictionary.Helpers; 3 | 4 | namespace Aictionary.Models; 5 | 6 | public class AppSettings 7 | { 8 | [JsonPropertyName("api_base_url")] 9 | public string ApiBaseUrl { get; set; } = "https://api.openai.com/v1"; 10 | 11 | [JsonPropertyName("api_key")] 12 | public string ApiKey { get; set; } = string.Empty; 13 | 14 | [JsonPropertyName("model")] 15 | public string Model { get; set; } = "gpt-4o-mini"; 16 | 17 | [JsonPropertyName("dictionary_path")] 18 | public string DictionaryPath { get; set; } = string.Empty; 19 | 20 | [JsonPropertyName("quick_query_hotkey")] 21 | public string QuickQueryHotkey { get; set; } = "Command+Shift+D"; 22 | 23 | [JsonPropertyName("dictionary_download_source")] 24 | public DictionaryDownloadSource DictionaryDownloadSource { get; set; } = LocaleHelper.GetDefaultDownloadSource(); 25 | } 26 | -------------------------------------------------------------------------------- /Aictionary/Models/Definition.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Aictionary.Models; 4 | 5 | public class Definition 6 | { 7 | [JsonPropertyName("pos")] 8 | public string Pos { get; set; } = string.Empty; 9 | 10 | [JsonPropertyName("explanation_en")] 11 | public string ExplanationEn { get; set; } = string.Empty; 12 | 13 | [JsonPropertyName("explanation_cn")] 14 | public string ExplanationCn { get; set; } = string.Empty; 15 | 16 | [JsonPropertyName("example_en")] 17 | public string ExampleEn { get; set; } = string.Empty; 18 | 19 | [JsonPropertyName("example_cn")] 20 | public string ExampleCn { get; set; } = string.Empty; 21 | } 22 | -------------------------------------------------------------------------------- /Aictionary/Models/DictionaryDownloadSource.cs: -------------------------------------------------------------------------------- 1 | namespace Aictionary.Models; 2 | 3 | public enum DictionaryDownloadSource 4 | { 5 | GitHub, 6 | Gitee 7 | } 8 | -------------------------------------------------------------------------------- /Aictionary/Models/QueryHistoryEntry.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Aictionary.Models; 5 | 6 | public class QueryHistoryEntry 7 | { 8 | [JsonPropertyName("word")] 9 | public string Word { get; set; } = string.Empty; 10 | 11 | [JsonPropertyName("queried_at")] 12 | public DateTime QueriedAt { get; set; } 13 | 14 | [JsonPropertyName("concise_definition")] 15 | public string? ConciseDefinition { get; set; } 16 | } 17 | -------------------------------------------------------------------------------- /Aictionary/Models/WordComparison.cs: -------------------------------------------------------------------------------- 1 | using System.Text.Json.Serialization; 2 | 3 | namespace Aictionary.Models; 4 | 5 | public class WordComparison 6 | { 7 | [JsonPropertyName("word_to_compare")] 8 | public string WordToCompare { get; set; } = string.Empty; 9 | 10 | [JsonPropertyName("analysis")] 11 | public string Analysis { get; set; } = string.Empty; 12 | } 13 | -------------------------------------------------------------------------------- /Aictionary/Models/WordDefinition.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Text.Json.Serialization; 3 | 4 | namespace Aictionary.Models; 5 | 6 | public class WordDefinition 7 | { 8 | [JsonPropertyName("word")] 9 | public string Word { get; set; } = string.Empty; 10 | 11 | [JsonPropertyName("pronunciation")] 12 | public string Pronunciation { get; set; } = string.Empty; 13 | 14 | [JsonPropertyName("concise_definition")] 15 | public string ConciseDefinition { get; set; } = string.Empty; 16 | 17 | [JsonPropertyName("forms")] 18 | public WordForms? Forms { get; set; } 19 | 20 | [JsonPropertyName("definitions")] 21 | public List Definitions { get; set; } = new(); 22 | 23 | [JsonPropertyName("comparison")] 24 | public List Comparison { get; set; } = new(); 25 | } 26 | -------------------------------------------------------------------------------- /Aictionary/Models/WordForms.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Linq; 3 | using System.Text.Json; 4 | 5 | namespace Aictionary.Models; 6 | 7 | public class WordForms : Dictionary 8 | { 9 | // Helper method to get value as string (for single values) 10 | public string? GetString(string key) 11 | { 12 | if (!TryGetValue(key, out var value)) 13 | return null; 14 | 15 | if (value is JsonElement element) 16 | { 17 | if (element.ValueKind == JsonValueKind.String) 18 | return element.GetString(); 19 | if (element.ValueKind == JsonValueKind.Array) 20 | return string.Join(", ", element.EnumerateArray().Select(e => e.GetString() ?? "")); 21 | } 22 | 23 | return value?.ToString(); 24 | } 25 | 26 | // Helper method to get value as string array (for array values like variant) 27 | public List? GetStringList(string key) 28 | { 29 | if (!TryGetValue(key, out var value)) 30 | return null; 31 | 32 | if (value is JsonElement element) 33 | { 34 | if (element.ValueKind == JsonValueKind.Array) 35 | return element.EnumerateArray().Select(e => e.GetString() ?? "").ToList(); 36 | if (element.ValueKind == JsonValueKind.String) 37 | return new List { element.GetString() ?? "" }; 38 | } 39 | 40 | if (value is string str) 41 | return new List { str }; 42 | 43 | return null; 44 | } 45 | } 46 | -------------------------------------------------------------------------------- /Aictionary/Program.cs: -------------------------------------------------------------------------------- 1 | using Avalonia; 2 | using Avalonia.ReactiveUI; 3 | using System; 4 | 5 | namespace Aictionary; 6 | 7 | sealed class Program 8 | { 9 | // Initialization code. Don't use any Avalonia, third-party APIs or any 10 | // SynchronizationContext-reliant code before AppMain is called: things aren't initialized 11 | // yet and stuff might break. 12 | [STAThread] 13 | public static void Main(string[] args) => BuildAvaloniaApp() 14 | .StartWithClassicDesktopLifetime(args); 15 | 16 | // Avalonia configuration, don't remove; also used by visual designer. 17 | public static AppBuilder BuildAvaloniaApp() 18 | => AppBuilder.Configure() 19 | .UsePlatformDetect() 20 | .WithInterFont() 21 | .LogToTrace() 22 | .UseReactiveUI(); 23 | } -------------------------------------------------------------------------------- /Aictionary/Services/DictionaryDownloadService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.IO.Compression; 5 | using System.Net.Http; 6 | using System.Threading.Tasks; 7 | using Aictionary.Models; 8 | 9 | namespace Aictionary.Services; 10 | 11 | public class DictionaryDownloadService : IDictionaryDownloadService 12 | { 13 | private readonly IDictionaryResourceService _resourceService; 14 | private readonly HttpClient _httpClient; 15 | private const int MinimumDictionaryFiles = 20000; 16 | 17 | public DictionaryDownloadService(IDictionaryResourceService resourceService) 18 | { 19 | Console.WriteLine("[DictionaryDownloadService] Constructor called"); 20 | _resourceService = resourceService; 21 | _httpClient = new HttpClient(); 22 | } 23 | 24 | public bool DictionaryExists(string dictionaryPath) 25 | { 26 | Console.WriteLine($"[DictionaryDownloadService] DictionaryExists check for path: {dictionaryPath}"); 27 | 28 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath)) 29 | { 30 | Console.WriteLine("[DictionaryDownloadService] Path is empty or doesn't exist"); 31 | return false; 32 | } 33 | 34 | try 35 | { 36 | var fileCount = Directory.GetFiles(dictionaryPath, "*.json").Length; 37 | var isValid = fileCount >= MinimumDictionaryFiles; 38 | Console.WriteLine($"[DictionaryDownloadService] Found {fileCount} files, minimum required: {MinimumDictionaryFiles}, valid: {isValid}"); 39 | return isValid; 40 | } 41 | catch (Exception ex) 42 | { 43 | Console.WriteLine($"[DictionaryDownloadService] Error checking dictionary: {ex.Message}"); 44 | return false; 45 | } 46 | } 47 | 48 | public async Task EnsureDictionaryExistsAsync(string dictionaryPath, DictionaryDownloadSource downloadSource, Action? progressCallback = null) 49 | { 50 | Console.WriteLine("[DictionaryDownloadService] EnsureDictionaryExistsAsync called"); 51 | Console.WriteLine($"[DictionaryDownloadService] Dictionary path: {dictionaryPath}"); 52 | Console.WriteLine($"[DictionaryDownloadService] Download source: {downloadSource}"); 53 | 54 | if (string.IsNullOrEmpty(dictionaryPath)) 55 | { 56 | var errorMessage = "Dictionary path is not configured."; 57 | Console.WriteLine($"[DictionaryDownloadService] {errorMessage}"); 58 | progressCallback?.Invoke(errorMessage, 0); 59 | throw new InvalidOperationException(errorMessage); 60 | } 61 | 62 | // Check if directory exists and has enough files 63 | if (Directory.Exists(dictionaryPath)) 64 | { 65 | try 66 | { 67 | var fileCount = Directory.GetFiles(dictionaryPath, "*.json").Length; 68 | Console.WriteLine($"[DictionaryDownloadService] Found {fileCount} files in existing directory"); 69 | 70 | if (fileCount >= MinimumDictionaryFiles) 71 | { 72 | Console.WriteLine("[DictionaryDownloadService] Dictionary already exists with sufficient files, skipping download"); 73 | progressCallback?.Invoke("Dictionary already exists. No download needed.", 100); 74 | return; 75 | } 76 | 77 | // Directory exists but doesn't have enough files - it's broken 78 | Console.WriteLine($"[DictionaryDownloadService] Dictionary is broken (only {fileCount} files, need {MinimumDictionaryFiles}), removing..."); 79 | progressCallback?.Invoke($"Dictionary incomplete ({fileCount} files). Removing and re-downloading...", 5); 80 | Directory.Delete(dictionaryPath, true); 81 | } 82 | catch (Exception ex) 83 | { 84 | Console.WriteLine($"[DictionaryDownloadService] Error checking/removing existing directory: {ex.Message}"); 85 | progressCallback?.Invoke("Error checking dictionary. Re-downloading...", 5); 86 | try 87 | { 88 | Directory.Delete(dictionaryPath, true); 89 | } 90 | catch 91 | { 92 | // Ignore deletion errors 93 | } 94 | } 95 | } 96 | 97 | Console.WriteLine("[DictionaryDownloadService] Starting download..."); 98 | await DownloadAndExtractDictionaryAsync(dictionaryPath, downloadSource, progressCallback); 99 | } 100 | 101 | private static readonly HashSet WindowsReservedNames = new HashSet(StringComparer.OrdinalIgnoreCase) 102 | { 103 | "CON", "PRN", "AUX", "NUL", 104 | "COM1", "COM2", "COM3", "COM4", "COM5", "COM6", "COM7", "COM8", "COM9", 105 | "LPT1", "LPT2", "LPT3", "LPT4", "LPT5", "LPT6", "LPT7", "LPT8", "LPT9" 106 | }; 107 | 108 | private static bool IsWindowsReservedName(string name) 109 | { 110 | if (string.IsNullOrEmpty(name)) 111 | return false; 112 | 113 | // Check if the name (without extension) is reserved 114 | var nameWithoutExtension = Path.GetFileNameWithoutExtension(name); 115 | return WindowsReservedNames.Contains(nameWithoutExtension); 116 | } 117 | 118 | private async Task DownloadAndExtractDictionaryAsync(string dictionaryPath, DictionaryDownloadSource downloadSource, Action? progressCallback) 119 | { 120 | Console.WriteLine("[DictionaryDownloadService] DownloadAndExtractDictionaryAsync started"); 121 | Console.WriteLine($"[DictionaryDownloadService] Dictionary path: {dictionaryPath}"); 122 | Console.WriteLine($"[DictionaryDownloadService] Download source: {downloadSource}"); 123 | Console.WriteLine($"[DictionaryDownloadService] progressCallback is null: {progressCallback == null}"); 124 | 125 | var parentDirectory = Directory.GetParent(dictionaryPath)?.FullName ?? Directory.GetCurrentDirectory(); 126 | var tempZipPath = Path.Combine(parentDirectory, "dictionary_temp.zip"); 127 | 128 | try 129 | { 130 | Console.WriteLine("[DictionaryDownloadService] Invoking progress callback: Fetching download URL..."); 131 | progressCallback?.Invoke("Fetching download URL...", 0); 132 | 133 | Console.WriteLine("[DictionaryDownloadService] Calling GetDictionaryDownloadUrlAsync..."); 134 | var downloadUrl = await _resourceService.GetDictionaryDownloadUrlAsync(downloadSource); 135 | Console.WriteLine($"[DictionaryDownloadService] Download URL: {downloadUrl}"); 136 | 137 | Console.WriteLine($"[DictionaryDownloadService] Temp zip path: {tempZipPath}"); 138 | 139 | Console.WriteLine("[DictionaryDownloadService] Invoking progress callback: Downloading dictionary..."); 140 | progressCallback?.Invoke("Downloading dictionary...", 5); 141 | 142 | Console.WriteLine("[DictionaryDownloadService] Starting HTTP request..."); 143 | using var response = await _httpClient.GetAsync(downloadUrl, HttpCompletionOption.ResponseHeadersRead); 144 | Console.WriteLine($"[DictionaryDownloadService] HTTP response status: {response.StatusCode}"); 145 | 146 | if (!response.IsSuccessStatusCode) 147 | { 148 | throw new HttpRequestException($"Failed to download dictionary. Server returned status code: {response.StatusCode}"); 149 | } 150 | 151 | var totalBytes = response.Content.Headers.ContentLength ?? -1; 152 | var canReportProgress = totalBytes != -1; 153 | Console.WriteLine($"[DictionaryDownloadService] Total bytes: {totalBytes}, Can report progress: {canReportProgress}"); 154 | 155 | if (!canReportProgress) 156 | { 157 | progressCallback?.Invoke("Downloading dictionary (size unknown)...", 5); 158 | } 159 | 160 | Console.WriteLine("[DictionaryDownloadService] Starting file download..."); 161 | await using (var fileStream = new FileStream(tempZipPath, FileMode.Create, FileAccess.Write, FileShare.None, bufferSize: 81920)) 162 | { 163 | await using var contentStream = await response.Content.ReadAsStreamAsync(); 164 | var buffer = new byte[81920]; // 80KB buffer for better performance 165 | var totalRead = 0L; 166 | int bytesRead; 167 | var lastProgressUpdate = 0L; 168 | 169 | while ((bytesRead = await contentStream.ReadAsync(buffer, 0, buffer.Length)) > 0) 170 | { 171 | await fileStream.WriteAsync(buffer, 0, bytesRead); 172 | totalRead += bytesRead; 173 | 174 | // Update progress more frequently (every 500KB) to avoid appearing stuck 175 | if (canReportProgress && (totalRead - lastProgressUpdate) >= (512 * 1024)) 176 | { 177 | lastProgressUpdate = totalRead; 178 | var downloadedMB = totalRead / (1024.0 * 1024); 179 | var totalMB = totalBytes / (1024.0 * 1024); 180 | var progress = 5 + (totalRead * 75.0 / totalBytes); 181 | 182 | Console.WriteLine($"[DictionaryDownloadService] Downloaded: {downloadedMB:F1} MB / {totalMB:F1} MB ({progress:F1}%)"); 183 | progressCallback?.Invoke($"Downloading: {downloadedMB:F1} MB / {totalMB:F1} MB", progress); 184 | } 185 | else if (!canReportProgress && (totalRead - lastProgressUpdate) >= (5 * 1024 * 1024)) 186 | { 187 | lastProgressUpdate = totalRead; 188 | var downloadedMB = totalRead / (1024.0 * 1024); 189 | Console.WriteLine($"[DictionaryDownloadService] Downloaded: {downloadedMB:F1} MB"); 190 | progressCallback?.Invoke($"Downloading: {downloadedMB:F1} MB", 50); 191 | } 192 | } 193 | 194 | Console.WriteLine($"[DictionaryDownloadService] Download complete. Total read: {totalRead} bytes ({totalRead / (1024.0 * 1024):F1} MB)"); 195 | progressCallback?.Invoke($"Download complete ({totalRead / (1024.0 * 1024):F1} MB)", 80); 196 | } 197 | 198 | // Ensure the directory doesn't exist before extraction 199 | if (Directory.Exists(dictionaryPath)) 200 | { 201 | Console.WriteLine("[DictionaryDownloadService] Removing existing dictionary directory before extraction..."); 202 | progressCallback?.Invoke("Preparing extraction...", 82); 203 | Directory.Delete(dictionaryPath, true); 204 | } 205 | 206 | Console.WriteLine("[DictionaryDownloadService] Starting extraction..."); 207 | progressCallback?.Invoke("Extracting dictionary files...", 85); 208 | 209 | try 210 | { 211 | // Extract with overwrite support for Windows 212 | using (var archive = System.IO.Compression.ZipFile.OpenRead(tempZipPath)) 213 | { 214 | var totalEntries = archive.Entries.Count; 215 | var extractedEntries = 0; 216 | var skippedEntries = 0; 217 | var lastProgressUpdate = 0; 218 | 219 | Console.WriteLine($"[DictionaryDownloadService] Extracting {totalEntries} files..."); 220 | 221 | foreach (var entry in archive.Entries) 222 | { 223 | // Check for Windows reserved names in the path 224 | var pathParts = entry.FullName.Split('/', '\\'); 225 | var hasReservedName = false; 226 | 227 | foreach (var part in pathParts) 228 | { 229 | if (IsWindowsReservedName(part)) 230 | { 231 | hasReservedName = true; 232 | Console.WriteLine($"[DictionaryDownloadService] Skipping entry with Windows reserved name: {entry.FullName}"); 233 | skippedEntries++; 234 | break; 235 | } 236 | } 237 | 238 | if (hasReservedName) 239 | { 240 | extractedEntries++; 241 | continue; 242 | } 243 | 244 | var destinationPath = Path.Combine(parentDirectory, entry.FullName); 245 | 246 | // Create directory if it's a directory entry 247 | if (string.IsNullOrEmpty(entry.Name)) 248 | { 249 | try 250 | { 251 | Directory.CreateDirectory(destinationPath); 252 | } 253 | catch (Exception ex) 254 | { 255 | Console.WriteLine($"[DictionaryDownloadService] Failed to create directory {destinationPath}: {ex.Message}"); 256 | skippedEntries++; 257 | } 258 | } 259 | else 260 | { 261 | try 262 | { 263 | // Ensure parent directory exists 264 | var destinationDir = Path.GetDirectoryName(destinationPath); 265 | if (!string.IsNullOrEmpty(destinationDir)) 266 | { 267 | Directory.CreateDirectory(destinationDir); 268 | } 269 | 270 | // Extract file with overwrite 271 | entry.ExtractToFile(destinationPath, overwrite: true); 272 | } 273 | catch (Exception ex) 274 | { 275 | Console.WriteLine($"[DictionaryDownloadService] Failed to extract {entry.FullName}: {ex.Message}"); 276 | skippedEntries++; 277 | } 278 | } 279 | 280 | extractedEntries++; 281 | 282 | // Update progress every 5% of files 283 | var progressPercent = (extractedEntries * 100) / totalEntries; 284 | if (progressPercent - lastProgressUpdate >= 5) 285 | { 286 | lastProgressUpdate = progressPercent; 287 | var extractionProgress = 85 + (progressPercent * 0.14); // 85% to 99% 288 | Console.WriteLine($"[DictionaryDownloadService] Extracted {extractedEntries}/{totalEntries} files ({progressPercent}%)"); 289 | progressCallback?.Invoke($"Extracting: {extractedEntries}/{totalEntries} files ({progressPercent}%)", extractionProgress); 290 | } 291 | } 292 | 293 | if (skippedEntries > 0) 294 | { 295 | Console.WriteLine($"[DictionaryDownloadService] Skipped {skippedEntries} problematic files during extraction"); 296 | } 297 | } 298 | 299 | Console.WriteLine("[DictionaryDownloadService] Extraction complete"); 300 | progressCallback?.Invoke("Verifying installation...", 99); 301 | 302 | // Verify extraction succeeded 303 | if (!Directory.Exists(dictionaryPath)) 304 | { 305 | throw new InvalidOperationException("Extraction failed: Dictionary directory not found after extraction. The archive may not contain the expected 'dictionary' folder."); 306 | } 307 | 308 | var fileCount = Directory.GetFiles(dictionaryPath, "*.json").Length; 309 | Console.WriteLine($"[DictionaryDownloadService] Verification: Found {fileCount} dictionary files"); 310 | 311 | if (fileCount < MinimumDictionaryFiles) 312 | { 313 | throw new InvalidOperationException($"Extraction incomplete: Only {fileCount} files found, expected at least {MinimumDictionaryFiles}. Please try downloading again."); 314 | } 315 | 316 | Console.WriteLine("[DictionaryDownloadService] Installation complete and verified"); 317 | progressCallback?.Invoke("Download complete!", 100); 318 | } 319 | catch (UnauthorizedAccessException ex) 320 | { 321 | Console.WriteLine($"[DictionaryDownloadService] Access denied during extraction: {ex.Message}"); 322 | throw new InvalidOperationException($"Cannot extract files - access denied. Please run the application as administrator or choose a different dictionary location. Error: {ex.Message}", ex); 323 | } 324 | catch (IOException ex) 325 | { 326 | Console.WriteLine($"[DictionaryDownloadService] IO error during extraction: {ex.Message}"); 327 | throw new InvalidOperationException($"File system error during extraction. Please ensure you have enough disk space and the dictionary path is accessible. Error: {ex.Message}", ex); 328 | } 329 | } 330 | catch (HttpRequestException ex) 331 | { 332 | Console.WriteLine($"[DictionaryDownloadService] Network error: {ex.Message}"); 333 | throw new InvalidOperationException($"Network error while downloading dictionary. Please check your internet connection and try again. Error: {ex.Message}", ex); 334 | } 335 | catch (TaskCanceledException ex) 336 | { 337 | Console.WriteLine($"[DictionaryDownloadService] Download timeout: {ex.Message}"); 338 | throw new InvalidOperationException($"Download timed out. Please check your internet connection and try again. Error: {ex.Message}", ex); 339 | } 340 | catch (Exception ex) when (ex is not InvalidOperationException) 341 | { 342 | Console.WriteLine($"[DictionaryDownloadService] Unexpected error: {ex.GetType().Name}: {ex.Message}"); 343 | Console.WriteLine($"[DictionaryDownloadService] Stack trace: {ex.StackTrace}"); 344 | throw new InvalidOperationException($"Unexpected error during dictionary download: {ex.Message}. Please try again or contact support if the problem persists.", ex); 345 | } 346 | finally 347 | { 348 | if (File.Exists(tempZipPath)) 349 | { 350 | try 351 | { 352 | Console.WriteLine("[DictionaryDownloadService] Deleting temp zip file"); 353 | File.Delete(tempZipPath); 354 | } 355 | catch (Exception ex) 356 | { 357 | Console.WriteLine($"[DictionaryDownloadService] Failed to delete temp file: {ex.Message}"); 358 | } 359 | } 360 | } 361 | } 362 | } 363 | -------------------------------------------------------------------------------- /Aictionary/Services/DictionaryResourceService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Aictionary.Models; 3 | 4 | namespace Aictionary.Services; 5 | 6 | public class DictionaryResourceService : IDictionaryResourceService 7 | { 8 | private const string GitHubDictionaryUrl = "https://github.com/ahpxex/open-english-dictionary/releases/download/v1.1/open-english-dictionary.zip"; 9 | private const string GiteeDictionaryUrl = "https://gitee.com/fanxiao25/open-english-dictionary/releases/download/v1.1/open-english-dictionary.zip"; 10 | 11 | public Task GetDictionaryDownloadUrlAsync(DictionaryDownloadSource source = DictionaryDownloadSource.GitHub) 12 | { 13 | var url = source switch 14 | { 15 | DictionaryDownloadSource.Gitee => GiteeDictionaryUrl, 16 | _ => GitHubDictionaryUrl 17 | }; 18 | 19 | return Task.FromResult(url); 20 | } 21 | } 22 | -------------------------------------------------------------------------------- /Aictionary/Services/DictionaryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | using Aictionary.Models; 8 | 9 | namespace Aictionary.Services; 10 | 11 | public class DictionaryService : IDictionaryService 12 | { 13 | private readonly ISettingsService _settingsService; 14 | private readonly JsonSerializerOptions _jsonOptions; 15 | 16 | public DictionaryService(ISettingsService settingsService) 17 | { 18 | _settingsService = settingsService; 19 | _jsonOptions = new JsonSerializerOptions 20 | { 21 | PropertyNameCaseInsensitive = true 22 | }; 23 | } 24 | 25 | public async Task GetDefinitionAsync(string word) 26 | { 27 | if (string.IsNullOrWhiteSpace(word)) 28 | return null; 29 | 30 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath; 31 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath)) 32 | return null; 33 | 34 | var fileName = $"{word.ToLower()}.json"; 35 | var filePath = Path.Combine(dictionaryPath, fileName); 36 | 37 | if (!File.Exists(filePath)) 38 | return null; 39 | 40 | try 41 | { 42 | var json = await File.ReadAllTextAsync(filePath); 43 | return JsonSerializer.Deserialize(json, _jsonOptions); 44 | } 45 | catch (Exception) 46 | { 47 | return null; 48 | } 49 | } 50 | 51 | public async Task> GetCachedWordsAsync() 52 | { 53 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath; 54 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath)) 55 | return new List(); 56 | 57 | try 58 | { 59 | var files = Directory.GetFiles(dictionaryPath, "*.json"); 60 | return files.Select(f => Path.GetFileNameWithoutExtension(f)).OrderBy(w => w).ToList(); 61 | } 62 | catch (Exception) 63 | { 64 | return new List(); 65 | } 66 | } 67 | 68 | public async Task DeleteCachedWordAsync(string word) 69 | { 70 | if (string.IsNullOrWhiteSpace(word)) 71 | return false; 72 | 73 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath; 74 | if (string.IsNullOrEmpty(dictionaryPath) || !Directory.Exists(dictionaryPath)) 75 | return false; 76 | 77 | var fileName = $"{word.ToLower()}.json"; 78 | var filePath = Path.Combine(dictionaryPath, fileName); 79 | 80 | if (!File.Exists(filePath)) 81 | return false; 82 | 83 | try 84 | { 85 | File.Delete(filePath); 86 | return true; 87 | } 88 | catch (Exception) 89 | { 90 | return false; 91 | } 92 | } 93 | 94 | public async Task SaveDefinitionAsync(WordDefinition definition) 95 | { 96 | if (definition == null || string.IsNullOrWhiteSpace(definition.Word)) 97 | return false; 98 | 99 | var dictionaryPath = _settingsService.CurrentSettings.DictionaryPath; 100 | if (string.IsNullOrEmpty(dictionaryPath)) 101 | return false; 102 | 103 | try 104 | { 105 | // Create directory if it doesn't exist 106 | if (!Directory.Exists(dictionaryPath)) 107 | { 108 | Directory.CreateDirectory(dictionaryPath); 109 | } 110 | 111 | var fileName = $"{definition.Word.ToLower()}.json"; 112 | var filePath = Path.Combine(dictionaryPath, fileName); 113 | 114 | var json = JsonSerializer.Serialize(definition, new JsonSerializerOptions { WriteIndented = true }); 115 | await File.WriteAllTextAsync(filePath, json); 116 | return true; 117 | } 118 | catch (Exception) 119 | { 120 | return false; 121 | } 122 | } 123 | } 124 | -------------------------------------------------------------------------------- /Aictionary/Services/HotkeyService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Diagnostics; 4 | using System.Globalization; 5 | using System.Linq; 6 | using System.Runtime.InteropServices; 7 | using System.Threading.Tasks; 8 | using SharpHook; 9 | using SharpHook.Data; 10 | using SharpHook.Native; 11 | using SharpHook.Providers; 12 | 13 | namespace Aictionary.Services; 14 | 15 | public sealed class HotkeyService : IHotkeyService, IDisposable 16 | { 17 | private readonly object _syncRoot = new(); 18 | private readonly Dictionary _hotkeys = new(StringComparer.OrdinalIgnoreCase); 19 | private readonly HashSet _activeHotkeys = new(StringComparer.OrdinalIgnoreCase); 20 | private readonly UioHookProvider _hookProvider; 21 | private readonly SimpleGlobalHook _hook; 22 | private Task? _hookTask; 23 | private bool _disposed; 24 | 25 | public HotkeyService() 26 | { 27 | _hookProvider = UioHookProvider.Instance; 28 | _hookProvider.PromptUserIfAxApiDisabled = false; 29 | _hook = new SimpleGlobalHook(GlobalHookType.Keyboard, _hookProvider, runAsyncOnBackgroundThread: true); 30 | _hook.KeyPressed += OnKeyPressed; 31 | _hook.KeyReleased += OnKeyReleased; 32 | _hook.HookDisabled += OnHookDisabled; 33 | 34 | EnsureHookRunning(); 35 | } 36 | 37 | public void RegisterHotkey(string hotkey, Action callback) 38 | { 39 | if (string.IsNullOrWhiteSpace(hotkey)) 40 | { 41 | Console.WriteLine("[HotkeyService] Empty hotkey provided"); 42 | return; 43 | } 44 | 45 | if (callback == null) 46 | { 47 | throw new ArgumentNullException(nameof(callback)); 48 | } 49 | 50 | var registration = TryCreateRegistration(hotkey, callback); 51 | if (registration == null) 52 | { 53 | Console.WriteLine($"[HotkeyService] Failed to parse hotkey: {hotkey}"); 54 | return; 55 | } 56 | 57 | lock (_syncRoot) 58 | { 59 | _hotkeys[hotkey] = registration; 60 | _activeHotkeys.Remove(hotkey); 61 | } 62 | 63 | Console.WriteLine($"[HotkeyService] Registered hotkey: {hotkey}"); 64 | EnsureHookRunning(); 65 | } 66 | 67 | public void UnregisterHotkey(string hotkey) 68 | { 69 | if (string.IsNullOrWhiteSpace(hotkey)) 70 | { 71 | return; 72 | } 73 | 74 | lock (_syncRoot) 75 | { 76 | if (_hotkeys.Remove(hotkey)) 77 | { 78 | _activeHotkeys.Remove(hotkey); 79 | Console.WriteLine($"[HotkeyService] Unregistered hotkey: {hotkey}"); 80 | } 81 | } 82 | } 83 | 84 | public void UnregisterAll() 85 | { 86 | lock (_syncRoot) 87 | { 88 | _hotkeys.Clear(); 89 | _activeHotkeys.Clear(); 90 | } 91 | 92 | Console.WriteLine("[HotkeyService] Cleared all registered hotkeys"); 93 | } 94 | 95 | public bool CheckAccessibilityPermissions() 96 | { 97 | var enabled = HasAccessibilityPermission(promptUser: false); 98 | 99 | if (enabled) 100 | { 101 | EnsureHookRunning(); 102 | Console.WriteLine("[HotkeyService] Accessibility permissions granted"); 103 | } 104 | else 105 | { 106 | Console.WriteLine("[HotkeyService] Accessibility permissions missing"); 107 | } 108 | 109 | return enabled; 110 | } 111 | 112 | public void RequestAccessibilityPermissions() 113 | { 114 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 115 | { 116 | Console.WriteLine("[HotkeyService] Accessibility permissions not required on this platform"); 117 | return; 118 | } 119 | 120 | Console.WriteLine("[HotkeyService] Opening System Preferences for accessibility permissions..."); 121 | OpenAccessibilityPreferences(); 122 | 123 | Console.WriteLine("[HotkeyService] Requesting accessibility permissions via system prompt..."); 124 | var enabled = HasAccessibilityPermission(promptUser: true); 125 | 126 | if (enabled) 127 | { 128 | Console.WriteLine("[HotkeyService] Accessibility permissions granted after prompt"); 129 | EnsureHookRunning(); 130 | } 131 | else 132 | { 133 | Console.WriteLine("[HotkeyService] Accessibility permissions still missing after prompt"); 134 | } 135 | } 136 | 137 | public void Dispose() 138 | { 139 | if (_disposed) 140 | { 141 | return; 142 | } 143 | 144 | _disposed = true; 145 | 146 | _hook.KeyPressed -= OnKeyPressed; 147 | _hook.KeyReleased -= OnKeyReleased; 148 | _hook.HookDisabled -= OnHookDisabled; 149 | 150 | try 151 | { 152 | if (_hook.IsRunning) 153 | { 154 | _hook.Stop(); 155 | } 156 | } 157 | catch (Exception ex) 158 | { 159 | Console.WriteLine($"[HotkeyService] Error while stopping global hook: {ex.Message}"); 160 | } 161 | 162 | _hook.Dispose(); 163 | lock (_syncRoot) 164 | { 165 | _hotkeys.Clear(); 166 | _activeHotkeys.Clear(); 167 | } 168 | } 169 | 170 | private void OnKeyPressed(object? sender, KeyboardHookEventArgs e) 171 | { 172 | HotkeyRegistration[] registrations; 173 | 174 | lock (_syncRoot) 175 | { 176 | registrations = _hotkeys.Values.ToArray(); 177 | } 178 | 179 | var actualMask = e.RawEvent.Mask; 180 | foreach (var registration in registrations) 181 | { 182 | if (registration.TriggerKey != e.Data.KeyCode) 183 | { 184 | continue; 185 | } 186 | 187 | if (!HasRequiredModifiers(actualMask, registration.RequiredMask)) 188 | { 189 | continue; 190 | } 191 | 192 | bool shouldInvoke; 193 | lock (_syncRoot) 194 | { 195 | shouldInvoke = _activeHotkeys.Add(registration.Hotkey); 196 | } 197 | 198 | if (shouldInvoke) 199 | { 200 | Task.Run(() => 201 | { 202 | try 203 | { 204 | registration.Callback(); 205 | } 206 | catch (Exception ex) 207 | { 208 | Console.WriteLine($"[HotkeyService] Hotkey callback error ({registration.Hotkey}): {ex.Message}"); 209 | } 210 | }); 211 | } 212 | } 213 | } 214 | 215 | private void OnKeyReleased(object? sender, KeyboardHookEventArgs e) 216 | { 217 | var releasedKey = e.Data.KeyCode; 218 | 219 | lock (_syncRoot) 220 | { 221 | foreach (var registration in _hotkeys.Values) 222 | { 223 | if (registration.TriggerKey == releasedKey || registration.ModifierKeyCodes.Contains(releasedKey)) 224 | { 225 | _activeHotkeys.Remove(registration.Hotkey); 226 | } 227 | } 228 | } 229 | } 230 | 231 | private void OnHookDisabled(object? sender, HookEventArgs e) 232 | { 233 | Console.WriteLine("[HotkeyService] Global hook disabled"); 234 | lock (_syncRoot) 235 | { 236 | _hookTask = null; 237 | _activeHotkeys.Clear(); 238 | } 239 | } 240 | 241 | private bool HasAccessibilityPermission(bool promptUser) 242 | { 243 | if (!RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 244 | { 245 | return true; 246 | } 247 | 248 | try 249 | { 250 | if (promptUser) 251 | { 252 | _hookProvider.PromptUserIfAxApiDisabled = true; 253 | } 254 | 255 | return _hookProvider.IsAxApiEnabled(promptUser); 256 | } 257 | catch (Exception ex) 258 | { 259 | Console.WriteLine($"[HotkeyService] Accessibility check failed: {ex.Message}"); 260 | return false; 261 | } 262 | finally 263 | { 264 | if (promptUser) 265 | { 266 | _hookProvider.PromptUserIfAxApiDisabled = false; 267 | } 268 | } 269 | } 270 | 271 | private void OpenAccessibilityPreferences() 272 | { 273 | try 274 | { 275 | var process = new Process 276 | { 277 | StartInfo = new ProcessStartInfo 278 | { 279 | FileName = "open", 280 | Arguments = "x-apple.systempreferences:com.apple.preference.security?Privacy_Accessibility", 281 | UseShellExecute = false, 282 | CreateNoWindow = true 283 | } 284 | }; 285 | 286 | if (process.Start()) 287 | { 288 | Console.WriteLine("[HotkeyService] System Preferences launched to Accessibility pane"); 289 | } 290 | else 291 | { 292 | Console.WriteLine("[HotkeyService] Failed to start System Preferences process"); 293 | } 294 | } 295 | catch (Exception ex) 296 | { 297 | Console.WriteLine($"[HotkeyService] Unable to open System Preferences: {ex.Message}"); 298 | } 299 | } 300 | 301 | private void EnsureHookRunning() 302 | { 303 | if (_disposed) 304 | { 305 | return; 306 | } 307 | 308 | if (!HasAccessibilityPermission(promptUser: false)) 309 | { 310 | Console.WriteLine("[HotkeyService] Global hook not started because accessibility permissions are missing"); 311 | return; 312 | } 313 | 314 | lock (_syncRoot) 315 | { 316 | if (_hookTask is { IsCompleted: false }) 317 | { 318 | return; 319 | } 320 | 321 | try 322 | { 323 | _hookTask = _hook.RunAsync(); 324 | _hookTask.ContinueWith(task => 325 | { 326 | if (task.IsFaulted) 327 | { 328 | var message = task.Exception?.GetBaseException().Message ?? "Unknown error"; 329 | Console.WriteLine($"[HotkeyService] Global hook stopped unexpectedly: {message}"); 330 | } 331 | 332 | lock (_syncRoot) 333 | { 334 | _activeHotkeys.Clear(); 335 | _hookTask = null; 336 | } 337 | }, TaskScheduler.Default); 338 | 339 | Console.WriteLine("[HotkeyService] Global hook started"); 340 | } 341 | catch (HookException ex) 342 | { 343 | Console.WriteLine($"[HotkeyService] Failed to start global hook: {ex.Message}"); 344 | } 345 | catch (Exception ex) 346 | { 347 | Console.WriteLine($"[HotkeyService] Unexpected error starting global hook: {ex.Message}"); 348 | } 349 | } 350 | } 351 | 352 | private static bool HasRequiredModifiers(EventMask actual, EventMask required) 353 | { 354 | if (required == EventMask.None) 355 | { 356 | return true; 357 | } 358 | 359 | if (!CheckModifier(actual, required, EventMask.Shift, EventMask.LeftShift, EventMask.RightShift)) 360 | { 361 | return false; 362 | } 363 | 364 | if (!CheckModifier(actual, required, EventMask.Ctrl, EventMask.LeftCtrl, EventMask.RightCtrl)) 365 | { 366 | return false; 367 | } 368 | 369 | if (!CheckModifier(actual, required, EventMask.Alt, EventMask.LeftAlt, EventMask.RightAlt)) 370 | { 371 | return false; 372 | } 373 | 374 | if (!CheckModifier(actual, required, EventMask.Meta, EventMask.LeftMeta, EventMask.RightMeta)) 375 | { 376 | return false; 377 | } 378 | 379 | return true; 380 | } 381 | 382 | private static bool CheckModifier(EventMask actual, EventMask required, EventMask anyMask, EventMask leftMask, EventMask rightMask) => 383 | (required & anyMask) == 0 || 384 | (actual & (anyMask | leftMask | rightMask)) != 0; 385 | 386 | private HotkeyRegistration? TryCreateRegistration(string hotkey, Action callback) 387 | { 388 | var parts = hotkey.Split('+', StringSplitOptions.RemoveEmptyEntries | StringSplitOptions.TrimEntries); 389 | 390 | if (parts.Length == 0) 391 | { 392 | return null; 393 | } 394 | 395 | var requiredMask = EventMask.None; 396 | var modifierKeyCodes = new HashSet(); 397 | var triggerKey = KeyCode.VcUndefined; 398 | 399 | foreach (var part in parts) 400 | { 401 | if (TryApplyModifier(part, ref requiredMask, modifierKeyCodes)) 402 | { 403 | continue; 404 | } 405 | 406 | if (TryParseKey(part, out var key)) 407 | { 408 | triggerKey = key; 409 | continue; 410 | } 411 | 412 | Console.WriteLine($"[HotkeyService] Unknown hotkey segment '{part}'"); 413 | return null; 414 | } 415 | 416 | if (triggerKey == KeyCode.VcUndefined) 417 | { 418 | Console.WriteLine("[HotkeyService] Hotkey must include a non-modifier key"); 419 | return null; 420 | } 421 | 422 | return new HotkeyRegistration 423 | { 424 | Hotkey = hotkey, 425 | TriggerKey = triggerKey, 426 | RequiredMask = requiredMask, 427 | ModifierKeyCodes = modifierKeyCodes, 428 | Callback = callback 429 | }; 430 | } 431 | 432 | private static bool TryApplyModifier(string token, ref EventMask mask, HashSet modifierKeyCodes) 433 | { 434 | var part = token.Trim(); 435 | 436 | if (part.Equals("Shift", StringComparison.OrdinalIgnoreCase)) 437 | { 438 | mask |= EventMask.Shift; 439 | modifierKeyCodes.Add(KeyCode.VcLeftShift); 440 | modifierKeyCodes.Add(KeyCode.VcRightShift); 441 | return true; 442 | } 443 | 444 | if (part.Equals("Control", StringComparison.OrdinalIgnoreCase) || 445 | part.Equals("Ctrl", StringComparison.OrdinalIgnoreCase)) 446 | { 447 | mask |= EventMask.Ctrl; 448 | modifierKeyCodes.Add(KeyCode.VcLeftControl); 449 | modifierKeyCodes.Add(KeyCode.VcRightControl); 450 | return true; 451 | } 452 | 453 | if (part.Equals("Alt", StringComparison.OrdinalIgnoreCase) || 454 | part.Equals("Option", StringComparison.OrdinalIgnoreCase)) 455 | { 456 | mask |= EventMask.Alt; 457 | modifierKeyCodes.Add(KeyCode.VcLeftAlt); 458 | modifierKeyCodes.Add(KeyCode.VcRightAlt); 459 | return true; 460 | } 461 | 462 | if (part.Equals("Command", StringComparison.OrdinalIgnoreCase) || 463 | part.Equals("Cmd", StringComparison.OrdinalIgnoreCase) || 464 | part.Equals("Meta", StringComparison.OrdinalIgnoreCase) || 465 | part.Equals("Super", StringComparison.OrdinalIgnoreCase)) 466 | { 467 | mask |= EventMask.Meta; 468 | modifierKeyCodes.Add(KeyCode.VcLeftMeta); 469 | modifierKeyCodes.Add(KeyCode.VcRightMeta); 470 | return true; 471 | } 472 | 473 | return false; 474 | } 475 | 476 | private static bool TryParseKey(string token, out KeyCode keyCode) 477 | { 478 | if (string.IsNullOrWhiteSpace(token)) 479 | { 480 | keyCode = KeyCode.VcUndefined; 481 | return false; 482 | } 483 | 484 | var part = token.Trim(); 485 | 486 | if (part.Length == 1) 487 | { 488 | var character = part[0]; 489 | 490 | if (char.IsLetter(character)) 491 | { 492 | var name = $"Vc{char.ToUpperInvariant(character)}"; 493 | if (Enum.TryParse(name, ignoreCase: true, out keyCode)) 494 | { 495 | return true; 496 | } 497 | } 498 | 499 | if (char.IsDigit(character)) 500 | { 501 | var name = $"Vc{character}"; 502 | if (Enum.TryParse(name, ignoreCase: false, out keyCode)) 503 | { 504 | return true; 505 | } 506 | } 507 | } 508 | 509 | if (part.Length > 1 && (part[0] == 'F' || part[0] == 'f') && int.TryParse(part.AsSpan(1), out var fnNumber)) 510 | { 511 | if (fnNumber is >= 1 and <= 24) 512 | { 513 | var name = $"VcF{fnNumber}"; 514 | if (Enum.TryParse(name, ignoreCase: false, out keyCode)) 515 | { 516 | return true; 517 | } 518 | } 519 | } 520 | 521 | if (SpecialKeys.TryGetValue(part, out keyCode)) 522 | { 523 | return true; 524 | } 525 | 526 | var normalized = CultureInfo.InvariantCulture.TextInfo.ToTitleCase(part.ToLowerInvariant()).Replace(" ", string.Empty); 527 | if (Enum.TryParse($"Vc{normalized}", ignoreCase: false, out keyCode)) 528 | { 529 | return true; 530 | } 531 | 532 | keyCode = KeyCode.VcUndefined; 533 | return false; 534 | } 535 | 536 | private static readonly Dictionary SpecialKeys = new(StringComparer.OrdinalIgnoreCase) 537 | { 538 | ["Space"] = KeyCode.VcSpace, 539 | ["Spacebar"] = KeyCode.VcSpace, 540 | ["Enter"] = KeyCode.VcEnter, 541 | ["Return"] = KeyCode.VcEnter, 542 | ["Tab"] = KeyCode.VcTab, 543 | ["Escape"] = KeyCode.VcEscape, 544 | ["Esc"] = KeyCode.VcEscape, 545 | ["Backspace"] = KeyCode.VcBackspace, 546 | ["Delete"] = KeyCode.VcDelete, 547 | ["Del"] = KeyCode.VcDelete, 548 | ["Home"] = KeyCode.VcHome, 549 | ["End"] = KeyCode.VcEnd, 550 | ["PageUp"] = KeyCode.VcPageUp, 551 | ["PageDown"] = KeyCode.VcPageDown, 552 | ["Up"] = KeyCode.VcUp, 553 | ["Down"] = KeyCode.VcDown, 554 | ["Left"] = KeyCode.VcLeft, 555 | ["Right"] = KeyCode.VcRight, 556 | }; 557 | 558 | private sealed class HotkeyRegistration 559 | { 560 | public required string Hotkey { get; init; } 561 | public required KeyCode TriggerKey { get; init; } 562 | public required EventMask RequiredMask { get; init; } 563 | public required HashSet ModifierKeyCodes { get; init; } 564 | public required Action Callback { get; init; } 565 | } 566 | } 567 | -------------------------------------------------------------------------------- /Aictionary/Services/IDictionaryDownloadService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Aictionary.Models; 4 | 5 | namespace Aictionary.Services; 6 | 7 | public interface IDictionaryDownloadService 8 | { 9 | Task EnsureDictionaryExistsAsync(string dictionaryPath, DictionaryDownloadSource downloadSource, Action? progressCallback = null); 10 | bool DictionaryExists(string dictionaryPath); 11 | } 12 | -------------------------------------------------------------------------------- /Aictionary/Services/IDictionaryResourceService.cs: -------------------------------------------------------------------------------- 1 | using System.Threading.Tasks; 2 | using Aictionary.Models; 3 | 4 | namespace Aictionary.Services; 5 | 6 | public interface IDictionaryResourceService 7 | { 8 | Task GetDictionaryDownloadUrlAsync(DictionaryDownloadSource source = DictionaryDownloadSource.GitHub); 9 | } 10 | -------------------------------------------------------------------------------- /Aictionary/Services/IDictionaryService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Aictionary.Models; 4 | 5 | namespace Aictionary.Services; 6 | 7 | public interface IDictionaryService 8 | { 9 | Task GetDefinitionAsync(string word); 10 | Task> GetCachedWordsAsync(); 11 | Task DeleteCachedWordAsync(string word); 12 | Task SaveDefinitionAsync(WordDefinition definition); 13 | } 14 | -------------------------------------------------------------------------------- /Aictionary/Services/IHotkeyService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | 3 | namespace Aictionary.Services; 4 | 5 | public interface IHotkeyService 6 | { 7 | void RegisterHotkey(string hotkey, Action callback); 8 | void UnregisterHotkey(string hotkey); 9 | void UnregisterAll(); 10 | bool CheckAccessibilityPermissions(); 11 | void RequestAccessibilityPermissions(); 12 | } 13 | -------------------------------------------------------------------------------- /Aictionary/Services/IOpenAIService.cs: -------------------------------------------------------------------------------- 1 | using System.Collections.Generic; 2 | using System.Threading.Tasks; 3 | using Aictionary.Models; 4 | 5 | namespace Aictionary.Services; 6 | 7 | public interface IOpenAIService 8 | { 9 | Task GenerateDefinitionAsync(string word); 10 | Task> GetAvailableModelsAsync(); 11 | } 12 | -------------------------------------------------------------------------------- /Aictionary/Services/IQueryHistoryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Threading.Tasks; 4 | using Aictionary.Models; 5 | 6 | namespace Aictionary.Services; 7 | 8 | public interface IQueryHistoryService 9 | { 10 | Task AddEntryAsync(string word, DateTime queriedAt, string? conciseDefinition = null); 11 | Task> GetEntriesAsync(); 12 | Task RemoveWordEntriesAsync(string word); 13 | } 14 | -------------------------------------------------------------------------------- /Aictionary/Services/ISettingsService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Threading.Tasks; 3 | using Aictionary.Models; 4 | 5 | namespace Aictionary.Services; 6 | 7 | public interface ISettingsService 8 | { 9 | Task LoadSettingsAsync(); 10 | Task SaveSettingsAsync(AppSettings settings); 11 | AppSettings CurrentSettings { get; } 12 | event EventHandler? SettingsChanged; 13 | } 14 | -------------------------------------------------------------------------------- /Aictionary/Services/OpenAIService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.ClientModel; 3 | using System.Collections.Generic; 4 | using System.Linq; 5 | using System.Text.Json; 6 | using System.Threading.Tasks; 7 | using Aictionary.Models; 8 | using OpenAI; 9 | using OpenAI.Chat; 10 | using OpenAI.Models; 11 | 12 | namespace Aictionary.Services; 13 | 14 | public class OpenAIService : IOpenAIService 15 | { 16 | private readonly ISettingsService _settingsService; 17 | private readonly JsonSerializerOptions _jsonOptions; 18 | 19 | public OpenAIService(ISettingsService settingsService) 20 | { 21 | _settingsService = settingsService; 22 | _jsonOptions = new JsonSerializerOptions 23 | { 24 | PropertyNameCaseInsensitive = true 25 | }; 26 | } 27 | 28 | private ChatClient CreateChatClient() 29 | { 30 | var settings = _settingsService.CurrentSettings; 31 | var apiKey = settings.ApiKey; 32 | var model = settings.Model; 33 | 34 | if (string.IsNullOrEmpty(apiKey)) 35 | { 36 | throw new InvalidOperationException("OpenAI API key is not configured. Please set it in Settings."); 37 | } 38 | 39 | // Create OpenAI client with custom endpoint if specified 40 | if (!string.IsNullOrEmpty(settings.ApiBaseUrl) && 41 | settings.ApiBaseUrl != "https://api.openai.com/v1") 42 | { 43 | var options = new OpenAIClientOptions 44 | { 45 | Endpoint = new Uri(settings.ApiBaseUrl) 46 | }; 47 | var client = new OpenAIClient(new ApiKeyCredential(apiKey), options); 48 | return client.GetChatClient(model); 49 | } 50 | 51 | return new ChatClient(model, apiKey); 52 | } 53 | 54 | public async Task GenerateDefinitionAsync(string word) 55 | { 56 | if (string.IsNullOrWhiteSpace(word)) 57 | { 58 | System.Console.WriteLine("[OpenAI] GenerateDefinition - Word is null or whitespace"); 59 | return null; 60 | } 61 | 62 | try 63 | { 64 | System.Console.WriteLine($"[OpenAI] GenerateDefinition START for word: '{word}'"); 65 | 66 | var chatClient = CreateChatClient(); 67 | 68 | System.Console.WriteLine("[OpenAI] ChatClient created successfully"); 69 | 70 | var prompt = """ 71 | 你是一位严谨的双语词典编纂专家。你的任务是为一个给定的英语单词及其近义词生成一份详细的中文解释,并以严格的 JSON 格式输出。 72 | 当 JSON 值中需要出现引号时,请使用中文双引号(“ ”),不要使用英文双引号。确保 JSON 中的所有字段都有完整的取值,不要遗漏或留空任何内容。 73 | 74 | 请精确遵循下面范例中提供的结构和内容深度。 75 | 76 | --- 77 | ### 范例 1 78 | 79 | **用户输入:** 80 | example 81 | 82 | **模型输出:** 83 | { 84 | "word": "example", 85 | "pronunciation": "uhg·zam·pl", 86 | "concise_definition": "n. 例子, 范例, 榜样", 87 | "forms": { 88 | "plural": "examples" 89 | }, 90 | "definitions": [ 91 | { 92 | "pos": "noun", 93 | "explanation_en": "A specific case or instance used to clarify a general rule, principle, or idea, aiming to help explain, clarify, or support a point.", 94 | "explanation_cn": "指用以说明一般性规则、原则或想法的一个具体事例或个案,旨在帮助解释、澄清或支持一个观点。", 95 | "example_en": "This is a classic example of how marketing can influence consumer behavior.", 96 | "example_cn": "这是一个关于市场营销如何影响消费者行为的经典范例。" 97 | }, 98 | { 99 | "pos": "noun", 100 | "explanation_en": "A model or standard for imitation, which can be positive (a role model) or negative (a cautionary tale).", 101 | "explanation_cn": "指一个可供他人模仿的榜样或典范,也可以指应引以为戒的反面教材。", 102 | "example_en": "Her dedication to the community sets a fine example for all of us.", 103 | "example_cn": "她对社区的奉献为我们所有人树立了一个好榜样。" 104 | } 105 | ], 106 | "comparison": [ 107 | { 108 | "word_to_compare": "sample", 109 | "analysis": "“Sample” (样本) 侧重于从一个整体中取出的一小部分,用以展示整体的质量、风格或特性。它强调“代表性”。例如,布料的样品、产品的试用装。而 “example” 是为了“说明”一个概念或规则,不一定来自一个更大的实体。" 110 | }, 111 | { 112 | "word_to_compare": "illustration", 113 | "analysis": "“Illustration” (图解/例证) 强调“视觉化”或“形象化”地解释说明。它可以是一个图片、图表,也可以是一个生动的故事,目的是让抽象的概念变得具体易懂。它的解释功能比 “example” 更强、更形象。" 114 | }, 115 | { 116 | "word_to_compare": "instance", 117 | "analysis": "“Instance” (实例) 与 “example” 非常接近,常可互换,但 “instance” 更侧重于指一个具体“事件”或“情况”的发生,作为某个现象存在的证据。它比 “example” 更具客观性和事实性,常用于比较正式的论述中。" 118 | } 119 | ] 120 | } 121 | 122 | --- 123 | 124 | ### 范例2 125 | 126 | **用户输入:** 127 | develop 128 | 129 | **模型输出:** 130 | { 131 | "word": "develop", 132 | "pronunciation": "duh·veh·luhp", 133 | "concise_definition": "v. 开发, 发展, 成长, 显影", 134 | "forms": { 135 | "third_person_singular": "develops", 136 | "past_tense": "developed", 137 | "past_participle": "developed", 138 | "present_participle": "developing" 139 | }, 140 | "definitions": [ 141 | { 142 | "pos": "verb", 143 | "explanation_en": "To create something or bring it to a more advanced or mature state, often from a basic or non-existent form (e.g., a skill, product, or idea).", 144 | "explanation_cn": "指使某事物(如技能、产品、想法)从无到有或从简单到复杂地被创造或变得更先进、更成熟。", 145 | "example_en": "The company is developing a new software to manage projects.", 146 | "example_cn": "该公司正在开发一款用于管理项目的新软件。" 147 | }, 148 | { 149 | "pos": "verb", 150 | "explanation_en": "For a situation or event to unfold, emerge, or undergo new changes over time.", 151 | "explanation_cn": "指(情况、事件)逐渐展开、显现或发生新的变化。", 152 | "example_en": "A crisis was developing in the financial markets.", 153 | "example_cn": "金融市场正酝酿着一场危机。" 154 | }, 155 | { 156 | "pos": "verb", 157 | "explanation_en": "To treat photographic film with chemicals to make the captured image visible.", 158 | "explanation_cn": "指冲洗胶片以使其图像显现。", 159 | "example_en": "I need to get these photos developed.", 160 | "example_cn": "我需要把这些照片冲洗出来。" 161 | } 162 | ], 163 | "comparison": [ 164 | { 165 | "word_to_compare": "evolve", 166 | "analysis": "“Evolve” (演变/进化) 强调一个长期的、渐进的、通常是自发的自然变化过程,从简单的形态向更复杂的形态发展。它常用于生物进化或社会、思想的长期演变。而 “develop” 通常暗示了有意识的努力和规划。" 167 | }, 168 | { 169 | "word_to_compare": "grow", 170 | "analysis": "“Grow” (生长/增长) 主要指尺寸、数量或程度上的增加,可以是自然的(如植物生长),也可以是抽象的(如信心增长)。“Develop” 更侧重于内在结构、能力或复杂性的提升,是质变,而 “grow” 更偏向于量变。" 171 | }, 172 | { 173 | "word_to_compare": "expand", 174 | "analysis": "“Expand” (扩张/扩大) 指在范围、规模、体积上的向外延伸。例如公司扩大市场、气球膨胀。它强调边界的延展。“Develop” 则是指内部变得更加完善和高级。" 175 | } 176 | ] 177 | } 178 | 179 | --- 180 | 181 | ### TASK 182 | 183 | 现在,请严格按照上面的范例,为用户输入的单词生成 JSON 输出。不要在 JSON 对象之外添加任何额外的说明或文字。 184 | """; 185 | 186 | System.Console.WriteLine(prompt); 187 | 188 | var messages = new ChatMessage[] 189 | { 190 | new SystemChatMessage(prompt), 191 | new UserChatMessage(word) 192 | }; 193 | 194 | var options = new ChatCompletionOptions 195 | { 196 | Temperature = 0.1f 197 | }; 198 | 199 | System.Console.WriteLine("[OpenAI] Sending request to API..."); 200 | var completion = await chatClient.CompleteChatAsync(messages, options); 201 | var response = completion.Value.Content[0].Text; 202 | 203 | System.Console.WriteLine($"[OpenAI] Received response (length: {response.Length} chars)"); 204 | System.Console.WriteLine($"[OpenAI] Response preview: \n{response}\n"); 205 | 206 | // Try to extract JSON if there's markdown code block 207 | var jsonStart = response.IndexOf('{'); 208 | var jsonEnd = response.LastIndexOf('}'); 209 | 210 | if (jsonStart >= 0 && jsonEnd >= 0) 211 | { 212 | var jsonContent = response.Substring(jsonStart, jsonEnd - jsonStart + 1); 213 | System.Console.WriteLine($"[OpenAI] Extracted JSON (length: {jsonContent.Length} chars)"); 214 | 215 | var definition = JsonSerializer.Deserialize(jsonContent, _jsonOptions); 216 | 217 | if (definition != null) 218 | { 219 | System.Console.WriteLine($"[OpenAI] Successfully deserialized definition for word: '{definition.Word}'"); 220 | } 221 | else 222 | { 223 | System.Console.WriteLine("[OpenAI] ERROR: Deserialization returned null"); 224 | } 225 | 226 | return definition; 227 | } 228 | else 229 | { 230 | System.Console.WriteLine($"[OpenAI] ERROR: Could not find JSON in response. jsonStart={jsonStart}, jsonEnd={jsonEnd}"); 231 | System.Console.WriteLine($"[OpenAI] Full response: {response}"); 232 | } 233 | 234 | return null; 235 | } 236 | catch (Exception ex) 237 | { 238 | System.Console.WriteLine($"[OpenAI] EXCEPTION in GenerateDefinition: {ex.GetType().Name}"); 239 | System.Console.WriteLine($"[OpenAI] Exception message: {ex.Message}"); 240 | System.Console.WriteLine($"[OpenAI] Stack trace: {ex.StackTrace}"); 241 | 242 | if (ex.InnerException != null) 243 | { 244 | System.Console.WriteLine($"[OpenAI] Inner exception: {ex.InnerException.GetType().Name}"); 245 | System.Console.WriteLine($"[OpenAI] Inner exception message: {ex.InnerException.Message}"); 246 | } 247 | 248 | return null; 249 | } 250 | } 251 | 252 | public async Task> GetAvailableModelsAsync() 253 | { 254 | try 255 | { 256 | var settings = _settingsService.CurrentSettings; 257 | var apiKey = settings.ApiKey; 258 | 259 | if (string.IsNullOrEmpty(apiKey)) 260 | { 261 | return new List(); 262 | } 263 | 264 | OpenAIClient client; 265 | 266 | if (!string.IsNullOrEmpty(settings.ApiBaseUrl) && 267 | settings.ApiBaseUrl != "https://api.openai.com/v1") 268 | { 269 | var options = new OpenAIClientOptions 270 | { 271 | Endpoint = new Uri(settings.ApiBaseUrl) 272 | }; 273 | client = new OpenAIClient(new ApiKeyCredential(apiKey), options); 274 | } 275 | else 276 | { 277 | client = new OpenAIClient(apiKey); 278 | } 279 | 280 | var modelClient = client.GetOpenAIModelClient(); 281 | var models = new List(); 282 | 283 | var result = await modelClient.GetModelsAsync(); 284 | var modelCollection = result.Value; 285 | 286 | foreach (var model in modelCollection) 287 | { 288 | models.Add(model.Id); 289 | } 290 | 291 | return models.OrderBy(m => m).ToList(); 292 | } 293 | catch (Exception) 294 | { 295 | return new List(); 296 | } 297 | } 298 | } 299 | -------------------------------------------------------------------------------- /Aictionary/Services/QueryHistoryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.IO; 4 | using System.Text.Json; 5 | using System.Threading; 6 | using System.Threading.Tasks; 7 | using Aictionary.Models; 8 | 9 | namespace Aictionary.Services; 10 | 11 | public class QueryHistoryService : IQueryHistoryService 12 | { 13 | private readonly string _historyFilePath; 14 | private readonly JsonSerializerOptions _jsonOptions; 15 | private readonly SemaphoreSlim _fileSemaphore = new(1, 1); 16 | 17 | public QueryHistoryService() 18 | { 19 | var appDirectory = AppDomain.CurrentDomain.BaseDirectory; 20 | _historyFilePath = Path.Combine(appDirectory, "query-history.json"); 21 | _jsonOptions = new JsonSerializerOptions 22 | { 23 | PropertyNameCaseInsensitive = true, 24 | WriteIndented = true 25 | }; 26 | 27 | if (!File.Exists(_historyFilePath)) 28 | { 29 | var emptyJson = JsonSerializer.Serialize(new List(), _jsonOptions); 30 | File.WriteAllText(_historyFilePath, emptyJson); 31 | } 32 | } 33 | 34 | public async Task AddEntryAsync(string word, DateTime queriedAt, string? conciseDefinition = null) 35 | { 36 | if (string.IsNullOrWhiteSpace(word)) 37 | { 38 | return; 39 | } 40 | 41 | var entry = new QueryHistoryEntry 42 | { 43 | Word = word.Trim(), 44 | QueriedAt = queriedAt, 45 | ConciseDefinition = conciseDefinition 46 | }; 47 | 48 | await _fileSemaphore.WaitAsync(); 49 | 50 | try 51 | { 52 | var entries = await ReadEntriesInternalAsync(); 53 | entries.Add(entry); 54 | await WriteEntriesInternalAsync(entries); 55 | } 56 | finally 57 | { 58 | _fileSemaphore.Release(); 59 | } 60 | } 61 | 62 | public async Task> GetEntriesAsync() 63 | { 64 | await _fileSemaphore.WaitAsync(); 65 | 66 | try 67 | { 68 | var entries = await ReadEntriesInternalAsync(); 69 | return entries.AsReadOnly(); 70 | } 71 | finally 72 | { 73 | _fileSemaphore.Release(); 74 | } 75 | } 76 | 77 | public async Task RemoveWordEntriesAsync(string word) 78 | { 79 | if (string.IsNullOrWhiteSpace(word)) 80 | { 81 | return; 82 | } 83 | 84 | await _fileSemaphore.WaitAsync(); 85 | 86 | try 87 | { 88 | var entries = await ReadEntriesInternalAsync(); 89 | entries.RemoveAll(e => string.Equals(e.Word, word.Trim(), StringComparison.OrdinalIgnoreCase)); 90 | await WriteEntriesInternalAsync(entries); 91 | } 92 | finally 93 | { 94 | _fileSemaphore.Release(); 95 | } 96 | } 97 | 98 | private async Task> ReadEntriesInternalAsync() 99 | { 100 | try 101 | { 102 | var json = await File.ReadAllTextAsync(_historyFilePath); 103 | var entries = JsonSerializer.Deserialize>(json, _jsonOptions); 104 | return entries ?? new List(); 105 | } 106 | catch (Exception) 107 | { 108 | return new List(); 109 | } 110 | } 111 | 112 | private async Task WriteEntriesInternalAsync(List entries) 113 | { 114 | try 115 | { 116 | var json = JsonSerializer.Serialize(entries, _jsonOptions); 117 | await File.WriteAllTextAsync(_historyFilePath, json); 118 | } 119 | catch (Exception) 120 | { 121 | // Intentionally swallow to avoid crashing the app on logging failure. 122 | } 123 | } 124 | } 125 | -------------------------------------------------------------------------------- /Aictionary/Services/QuickQueryService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Runtime.InteropServices; 3 | using System.Threading.Tasks; 4 | using Aictionary.ViewModels; 5 | using Aictionary.Views; 6 | using Avalonia; 7 | using Avalonia.Controls; 8 | using Avalonia.Controls.ApplicationLifetimes; 9 | using Avalonia.Input.Platform; 10 | using Avalonia.Threading; 11 | using SharpHook; 12 | using SharpHook.Data; 13 | using SharpHook.Native; 14 | 15 | namespace Aictionary.Services; 16 | 17 | public class QuickQueryService 18 | { 19 | private readonly IHotkeyService _hotkeyService; 20 | private readonly ISettingsService _settingsService; 21 | private readonly EventSimulator _eventSimulator; 22 | 23 | public QuickQueryService(IHotkeyService hotkeyService, ISettingsService settingsService) 24 | { 25 | _hotkeyService = hotkeyService; 26 | _settingsService = settingsService; 27 | _eventSimulator = new EventSimulator(); 28 | } 29 | 30 | public void Initialize() 31 | { 32 | // Check permissions before registering 33 | if (!_hotkeyService.CheckAccessibilityPermissions()) 34 | { 35 | Console.WriteLine("[QuickQueryService] Accessibility permissions not granted. Hotkey not registered."); 36 | Console.WriteLine("[QuickQueryService] Please grant accessibility permissions in Settings > Keyboard."); 37 | return; 38 | } 39 | 40 | RegisterQuickQueryHotkey(); 41 | 42 | // Re-register when settings change 43 | _settingsService.SettingsChanged += (sender, args) => 44 | { 45 | RegisterQuickQueryHotkey(); 46 | }; 47 | } 48 | 49 | public void ReregisterHotkey() 50 | { 51 | // This method can be called after permissions are granted 52 | if (_hotkeyService.CheckAccessibilityPermissions()) 53 | { 54 | RegisterQuickQueryHotkey(); 55 | } 56 | } 57 | 58 | private void RegisterQuickQueryHotkey() 59 | { 60 | var hotkey = _settingsService.CurrentSettings.QuickQueryHotkey; 61 | if (string.IsNullOrEmpty(hotkey)) 62 | { 63 | Console.WriteLine("[QuickQueryService] No hotkey configured"); 64 | return; 65 | } 66 | 67 | Console.WriteLine($"[QuickQueryService] Registering quick query hotkey: {hotkey}"); 68 | 69 | _hotkeyService.UnregisterAll(); 70 | _hotkeyService.RegisterHotkey(hotkey, async () => 71 | { 72 | await Dispatcher.UIThread.InvokeAsync(async () => 73 | { 74 | await HandleQuickQuery(); 75 | }); 76 | }); 77 | } 78 | 79 | private async Task HandleQuickQuery() 80 | { 81 | try 82 | { 83 | Console.WriteLine("[QuickQueryService] Quick query triggered"); 84 | 85 | // Step 1: Simulate Cmd+C (or Ctrl+C on Windows/Linux) to copy selected text 86 | await SimulateCopyShortcut(); 87 | 88 | // Wait a bit for the copy operation to complete 89 | await Task.Delay(100); 90 | 91 | // Get or create main window 92 | var mainWindow = GetMainWindow(); 93 | if (mainWindow == null) 94 | { 95 | Console.WriteLine("[QuickQueryService] Could not get main window"); 96 | return; 97 | } 98 | 99 | // Get the selected text from clipboard via TopLevel 100 | var topLevel = TopLevel.GetTopLevel(mainWindow); 101 | var clipboard = topLevel?.Clipboard; 102 | if (clipboard == null) 103 | { 104 | Console.WriteLine("[QuickQueryService] Clipboard not available"); 105 | return; 106 | } 107 | 108 | // Get text from clipboard 109 | var text = await clipboard.GetTextAsync(); 110 | if (string.IsNullOrWhiteSpace(text)) 111 | { 112 | Console.WriteLine("[QuickQueryService] No text in clipboard"); 113 | return; 114 | } 115 | 116 | Console.WriteLine($"[QuickQueryService] Text from clipboard: '{text}'"); 117 | 118 | // Show and activate the window 119 | if (!mainWindow.IsVisible) 120 | { 121 | mainWindow.Show(); 122 | } 123 | 124 | mainWindow.Activate(); 125 | mainWindow.BringIntoView(); 126 | 127 | // Set the search text and trigger search 128 | if (mainWindow.DataContext is MainWindowViewModel viewModel) 129 | { 130 | viewModel.SearchText = text.Trim(); 131 | // Execute the search command (no await needed, it returns IObservable) 132 | viewModel.SearchCommand.Execute().Subscribe(); 133 | } 134 | 135 | Console.WriteLine("[QuickQueryService] Quick query completed"); 136 | } 137 | catch (Exception ex) 138 | { 139 | Console.WriteLine($"[QuickQueryService] Error in HandleQuickQuery: {ex.Message}"); 140 | } 141 | } 142 | 143 | private async Task SimulateCopyShortcut() 144 | { 145 | try 146 | { 147 | Console.WriteLine("[QuickQueryService] Simulating copy shortcut..."); 148 | 149 | // Determine which modifier key to use based on OS 150 | var modifierKeyCode = RuntimeInformation.IsOSPlatform(OSPlatform.OSX) 151 | ? KeyCode.VcLeftMeta // Command on macOS 152 | : KeyCode.VcLeftControl; // Ctrl on Windows/Linux 153 | 154 | // Press modifier key (Command or Ctrl) 155 | _eventSimulator.SimulateKeyPress(modifierKeyCode); 156 | await Task.Delay(50); 157 | 158 | // Press C key 159 | _eventSimulator.SimulateKeyPress(KeyCode.VcC); 160 | await Task.Delay(50); 161 | 162 | // Release C key 163 | _eventSimulator.SimulateKeyRelease(KeyCode.VcC); 164 | await Task.Delay(50); 165 | 166 | // Release modifier key 167 | _eventSimulator.SimulateKeyRelease(modifierKeyCode); 168 | 169 | Console.WriteLine("[QuickQueryService] Copy shortcut simulated"); 170 | } 171 | catch (Exception ex) 172 | { 173 | Console.WriteLine($"[QuickQueryService] Error simulating copy shortcut: {ex.Message}"); 174 | } 175 | } 176 | 177 | private MainWindow? GetMainWindow() 178 | { 179 | if (Application.Current?.ApplicationLifetime is IClassicDesktopStyleApplicationLifetime desktop) 180 | { 181 | return desktop.MainWindow as MainWindow; 182 | } 183 | 184 | return null; 185 | } 186 | } 187 | -------------------------------------------------------------------------------- /Aictionary/Services/SettingsService.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using System.Text.Json; 4 | using System.Threading.Tasks; 5 | using Aictionary.Helpers; 6 | using Aictionary.Models; 7 | using Microsoft.Extensions.Configuration; 8 | 9 | namespace Aictionary.Services; 10 | 11 | public class SettingsService : ISettingsService 12 | { 13 | private readonly string _settingsFilePath; 14 | private readonly IConfigurationRoot _configuration; 15 | private AppSettings _currentSettings; 16 | 17 | public event EventHandler? SettingsChanged; 18 | 19 | public SettingsService() 20 | { 21 | var appDirectory = AppDomain.CurrentDomain.BaseDirectory; 22 | _settingsFilePath = Path.Combine(appDirectory, "appsettings.json"); 23 | 24 | // Ensure settings file exists 25 | if (!File.Exists(_settingsFilePath)) 26 | { 27 | var defaultSettings = new AppSettings 28 | { 29 | DictionaryPath = Path.Combine(appDirectory, "dictionary"), 30 | ApiBaseUrl = "https://api.openai.com/v1", 31 | Model = "gpt-4o-mini" 32 | }; 33 | var json = JsonSerializer.Serialize(defaultSettings, new JsonSerializerOptions { WriteIndented = true }); 34 | File.WriteAllText(_settingsFilePath, json); 35 | } 36 | 37 | _configuration = new ConfigurationBuilder() 38 | .SetBasePath(appDirectory) 39 | .AddJsonFile("appsettings.json", optional: false, reloadOnChange: true) 40 | .Build(); 41 | 42 | _currentSettings = new AppSettings 43 | { 44 | DictionaryPath = Path.Combine(appDirectory, "dictionary") 45 | }; 46 | 47 | // Subscribe to configuration changes 48 | _configuration.GetReloadToken().RegisterChangeCallback(_ => 49 | { 50 | Console.WriteLine("[SettingsService] Configuration file changed, reloading..."); 51 | LoadSettingsFromConfiguration(); 52 | }, null); 53 | } 54 | 55 | public AppSettings CurrentSettings => _currentSettings; 56 | 57 | public async Task LoadSettingsAsync() 58 | { 59 | await Task.CompletedTask; 60 | LoadSettingsFromConfiguration(); 61 | return _currentSettings; 62 | } 63 | 64 | private void LoadSettingsFromConfiguration() 65 | { 66 | var appDirectory = AppDomain.CurrentDomain.BaseDirectory; 67 | 68 | _currentSettings.ApiBaseUrl = _configuration["ApiBaseUrl"] ?? "https://api.openai.com/v1"; 69 | _currentSettings.ApiKey = _configuration["ApiKey"] ?? Environment.GetEnvironmentVariable("OPENAI_API_KEY") ?? string.Empty; 70 | _currentSettings.Model = _configuration["Model"] ?? "gpt-4o-mini"; 71 | _currentSettings.DictionaryPath = _configuration["DictionaryPath"] ?? Path.Combine(appDirectory, "dictionary"); 72 | _currentSettings.QuickQueryHotkey = _configuration["QuickQueryHotkey"] ?? "Command+Shift+D"; 73 | 74 | // Load DictionaryDownloadSource from config, or use auto-detected default 75 | var downloadSourceStr = _configuration["DictionaryDownloadSource"]; 76 | if (!string.IsNullOrEmpty(downloadSourceStr) && Enum.TryParse(downloadSourceStr, out var downloadSource)) 77 | { 78 | _currentSettings.DictionaryDownloadSource = downloadSource; 79 | } 80 | else 81 | { 82 | _currentSettings.DictionaryDownloadSource = LocaleHelper.GetDefaultDownloadSource(); 83 | } 84 | } 85 | 86 | public async Task SaveSettingsAsync(AppSettings settings) 87 | { 88 | _currentSettings = settings; 89 | 90 | var settingsToSave = new 91 | { 92 | ApiBaseUrl = settings.ApiBaseUrl, 93 | ApiKey = settings.ApiKey, 94 | Model = settings.Model, 95 | DictionaryPath = settings.DictionaryPath, 96 | QuickQueryHotkey = settings.QuickQueryHotkey, 97 | DictionaryDownloadSource = settings.DictionaryDownloadSource.ToString() 98 | }; 99 | 100 | var json = JsonSerializer.Serialize(settingsToSave, new JsonSerializerOptions { WriteIndented = true }); 101 | await File.WriteAllTextAsync(_settingsFilePath, json); 102 | 103 | SettingsChanged?.Invoke(this, EventArgs.Empty); 104 | } 105 | } 106 | -------------------------------------------------------------------------------- /Aictionary/ViewLocator.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using Avalonia.Controls; 3 | using Avalonia.Controls.Templates; 4 | using Aictionary.ViewModels; 5 | 6 | namespace Aictionary; 7 | 8 | public class ViewLocator : IDataTemplate 9 | { 10 | public Control? Build(object? param) 11 | { 12 | if (param is null) 13 | return null; 14 | 15 | var name = param.GetType().FullName!.Replace("ViewModel", "View", StringComparison.Ordinal); 16 | var type = Type.GetType(name); 17 | 18 | if (type != null) 19 | { 20 | return (Control)Activator.CreateInstance(type)!; 21 | } 22 | 23 | return new TextBlock { Text = "Not Found: " + name }; 24 | } 25 | 26 | public bool Match(object? data) 27 | { 28 | return data is ViewModelBase; 29 | } 30 | } -------------------------------------------------------------------------------- /Aictionary/ViewModels/DownloadProgressViewModel.cs: -------------------------------------------------------------------------------- 1 | using ReactiveUI; 2 | 3 | namespace Aictionary.ViewModels; 4 | 5 | public class DownloadProgressViewModel : ViewModelBase 6 | { 7 | private string _statusMessage = "Initializing download..."; 8 | private bool _isIndeterminate = true; 9 | private double _progress; 10 | private bool _isCompleted; 11 | 12 | public string StatusMessage 13 | { 14 | get => _statusMessage; 15 | set => this.RaiseAndSetIfChanged(ref _statusMessage, value); 16 | } 17 | 18 | public bool IsIndeterminate 19 | { 20 | get => _isIndeterminate; 21 | set => this.RaiseAndSetIfChanged(ref _isIndeterminate, value); 22 | } 23 | 24 | public double Progress 25 | { 26 | get => _progress; 27 | set => this.RaiseAndSetIfChanged(ref _progress, value); 28 | } 29 | 30 | public bool IsCompleted 31 | { 32 | get => _isCompleted; 33 | set => this.RaiseAndSetIfChanged(ref _isCompleted, value); 34 | } 35 | } 36 | -------------------------------------------------------------------------------- /Aictionary/ViewModels/MainWindowViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Reactive; 3 | using System.Threading.Tasks; 4 | using Aictionary.Models; 5 | using Aictionary.Services; 6 | using ReactiveUI; 7 | 8 | namespace Aictionary.ViewModels; 9 | 10 | public class MainWindowViewModel : ViewModelBase 11 | { 12 | private readonly IDictionaryService _dictionaryService; 13 | private readonly IOpenAIService _openAIService; 14 | private readonly ISettingsService _settingsService; 15 | private readonly IQueryHistoryService _queryHistoryService; 16 | 17 | private string _searchText = string.Empty; 18 | private WordDefinition? _currentDefinition; 19 | private bool _isLoading; 20 | private string _errorMessage = string.Empty; 21 | 22 | public event EventHandler? OpenSettingsRequested; 23 | public event EventHandler? OpenStatisticsRequested; 24 | 25 | public MainWindowViewModel( 26 | IDictionaryService dictionaryService, 27 | IOpenAIService openAIService, 28 | ISettingsService settingsService, 29 | IQueryHistoryService queryHistoryService) 30 | { 31 | _dictionaryService = dictionaryService; 32 | _openAIService = openAIService; 33 | _settingsService = settingsService; 34 | _queryHistoryService = queryHistoryService; 35 | 36 | SearchCommand = ReactiveCommand.CreateFromTask( 37 | SearchAsync, 38 | this.WhenAnyValue(x => x.SearchText, text => !string.IsNullOrWhiteSpace(text)) 39 | ); 40 | 41 | OpenSettingsCommand = ReactiveCommand.Create(OpenSettings); 42 | OpenStatisticsCommand = ReactiveCommand.Create(OpenStatistics); 43 | } 44 | 45 | public string SearchText 46 | { 47 | get => _searchText; 48 | set => this.RaiseAndSetIfChanged(ref _searchText, value); 49 | } 50 | 51 | public WordDefinition? CurrentDefinition 52 | { 53 | get => _currentDefinition; 54 | set => this.RaiseAndSetIfChanged(ref _currentDefinition, value); 55 | } 56 | 57 | public bool IsLoading 58 | { 59 | get => _isLoading; 60 | set => this.RaiseAndSetIfChanged(ref _isLoading, value); 61 | } 62 | 63 | public string ErrorMessage 64 | { 65 | get => _errorMessage; 66 | set => this.RaiseAndSetIfChanged(ref _errorMessage, value); 67 | } 68 | 69 | public ReactiveCommand SearchCommand { get; } 70 | public ReactiveCommand OpenSettingsCommand { get; } 71 | public ReactiveCommand OpenStatisticsCommand { get; } 72 | 73 | private async Task SearchAsync() 74 | { 75 | var query = SearchText.Trim(); 76 | if (string.IsNullOrWhiteSpace(query)) 77 | { 78 | return; 79 | } 80 | 81 | IsLoading = true; 82 | ErrorMessage = string.Empty; 83 | CurrentDefinition = null; 84 | 85 | System.Console.WriteLine($"[MainViewModel] SearchAsync START for word: '{query}'"); 86 | 87 | try 88 | { 89 | // First, try to get from local cache 90 | System.Console.WriteLine("[MainViewModel] Attempting to get definition from cache..."); 91 | var definition = await _dictionaryService.GetDefinitionAsync(query); 92 | var fromCache = definition != null; 93 | 94 | if (fromCache) 95 | { 96 | System.Console.WriteLine("[MainViewModel] Definition found in cache"); 97 | } 98 | else 99 | { 100 | System.Console.WriteLine("[MainViewModel] Definition not found in cache"); 101 | } 102 | 103 | if (definition == null) 104 | { 105 | // If not found in cache, query OpenAI 106 | System.Console.WriteLine("[MainViewModel] Querying OpenAI for definition..."); 107 | definition = await _openAIService.GenerateDefinitionAsync(query); 108 | 109 | // Save the AI-generated definition to cache 110 | if (definition != null) 111 | { 112 | System.Console.WriteLine("[MainViewModel] AI generated definition successfully, saving to cache..."); 113 | await _dictionaryService.SaveDefinitionAsync(definition); 114 | System.Console.WriteLine("[MainViewModel] Definition saved to cache"); 115 | } 116 | else 117 | { 118 | System.Console.WriteLine("[MainViewModel] OpenAI returned null definition"); 119 | } 120 | } 121 | 122 | if (definition != null) 123 | { 124 | CurrentDefinition = definition; 125 | 126 | // Add to history with concise definition 127 | await _queryHistoryService.AddEntryAsync(query, DateTime.UtcNow, definition.ConciseDefinition); 128 | 129 | if (!fromCache) 130 | { 131 | ErrorMessage = "Definition generated by AI and saved to cache."; 132 | } 133 | System.Console.WriteLine("[MainViewModel] Definition set successfully"); 134 | } 135 | else 136 | { 137 | // Add to history without definition 138 | await _queryHistoryService.AddEntryAsync(query, DateTime.UtcNow); 139 | 140 | System.Console.WriteLine("[MainViewModel] ERROR: No definition available (both cache and AI failed)"); 141 | ErrorMessage = "Could not find or generate definition for this word. Check console logs for details."; 142 | } 143 | } 144 | catch (Exception ex) 145 | { 146 | System.Console.WriteLine($"[MainViewModel] EXCEPTION in SearchAsync: {ex.GetType().Name}"); 147 | System.Console.WriteLine($"[MainViewModel] Exception message: {ex.Message}"); 148 | System.Console.WriteLine($"[MainViewModel] Stack trace: {ex.StackTrace}"); 149 | ErrorMessage = $"Error: {ex.Message}"; 150 | } 151 | finally 152 | { 153 | IsLoading = false; 154 | System.Console.WriteLine("[MainViewModel] SearchAsync END"); 155 | } 156 | } 157 | 158 | private void OpenSettings() 159 | { 160 | OpenSettingsRequested?.Invoke(this, EventArgs.Empty); 161 | } 162 | 163 | private void OpenStatistics() 164 | { 165 | OpenStatisticsRequested?.Invoke(this, EventArgs.Empty); 166 | } 167 | } 168 | -------------------------------------------------------------------------------- /Aictionary/ViewModels/SettingsViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Linq; 5 | using System.Reactive; 6 | using System.Reactive.Linq; 7 | using System.Threading.Tasks; 8 | using System.Runtime.InteropServices; 9 | using Aictionary.Models; 10 | using Aictionary.Services; 11 | using DynamicData; 12 | using ReactiveUI; 13 | 14 | namespace Aictionary.ViewModels; 15 | 16 | public class SettingsViewModel : ViewModelBase 17 | { 18 | private readonly ISettingsService _settingsService; 19 | private readonly IDictionaryService _dictionaryService; 20 | private readonly IOpenAIService _openAIService; 21 | private readonly IHotkeyService? _hotkeyService; 22 | private readonly QuickQueryService? _quickQueryService; 23 | 24 | private string _apiBaseUrl = string.Empty; 25 | private string _apiKey = string.Empty; 26 | private string _model = string.Empty; 27 | private string _dictionaryPath = string.Empty; 28 | private string _statusMessage = string.Empty; 29 | private bool _isApiKeyVisible = false; 30 | private DictionaryDownloadSource _selectedDownloadSource; 31 | private bool _isLoadingModels = false; 32 | private string _searchText = string.Empty; 33 | private bool _isLoadingCachedWords = false; 34 | private string _quickQueryHotkey = "Command+Shift+D"; 35 | private bool _hasAccessibilityPermissions = true; 36 | private string _permissionStatusMessage = string.Empty; 37 | private bool _isCheckingPermissions = false; 38 | private bool _isAccessibilitySectionVisible; 39 | 40 | private readonly ObservableCollection _availableModels = new(); 41 | private readonly ObservableCollection _cachedWords = new(); 42 | private readonly ObservableCollection _filteredCachedWords = new(); 43 | private readonly ObservableCollection _availableDownloadSources = new() 44 | { 45 | DictionaryDownloadSource.GitHub, 46 | DictionaryDownloadSource.Gitee 47 | }; 48 | 49 | public SettingsViewModel( 50 | ISettingsService settingsService, 51 | IDictionaryService dictionaryService, 52 | IOpenAIService openAIService, 53 | IHotkeyService? hotkeyService = null, 54 | QuickQueryService? quickQueryService = null) 55 | { 56 | _settingsService = settingsService; 57 | _dictionaryService = dictionaryService; 58 | _openAIService = openAIService; 59 | _hotkeyService = hotkeyService; 60 | _quickQueryService = quickQueryService; 61 | 62 | IsAccessibilitySectionVisible = _hotkeyService != null && RuntimeInformation.IsOSPlatform(OSPlatform.OSX); 63 | 64 | DownloadDictionaryCommand = ReactiveCommand.CreateFromTask(DownloadDictionaryAsync); 65 | RefreshModelsCommand = ReactiveCommand.CreateFromTask(RefreshModelsAsync); 66 | ToggleApiKeyVisibilityCommand = ReactiveCommand.Create(ToggleApiKeyVisibility); 67 | RefreshCachedWordsCommand = ReactiveCommand.CreateFromTask(RefreshCachedWordsAsync); 68 | var canManageAccessibility = this.WhenAnyValue(x => x.IsAccessibilitySectionVisible); 69 | RequestAccessibilityPermissionsCommand = ReactiveCommand.Create(RequestAccessibilityPermissions, canManageAccessibility); 70 | CheckPermissionsCommand = ReactiveCommand.CreateFromTask(CheckPermissionsAsync, canManageAccessibility); 71 | 72 | LoadSettings(); 73 | _ = Task.Run(async () => await CheckPermissionsAsync()); 74 | 75 | // Auto-load cached words on startup 76 | _ = Task.Run(async () => 77 | { 78 | await RefreshCachedWordsAsync(); 79 | }); 80 | 81 | // Initialize available models with current model if it exists 82 | if (!string.IsNullOrEmpty(Model)) 83 | { 84 | _availableModels.Add(Model); 85 | } 86 | 87 | // Auto-load models list only if no model is selected 88 | if (string.IsNullOrEmpty(Model)) 89 | { 90 | _ = Task.Run(async () => 91 | { 92 | await RefreshModelsAsync(); 93 | }); 94 | } 95 | 96 | // Auto-save settings when any property changes 97 | this.WhenAnyValue( 98 | x => x.ApiBaseUrl, 99 | x => x.ApiKey, 100 | x => x.Model, 101 | x => x.DictionaryPath, 102 | x => x.QuickQueryHotkey, 103 | x => x.SelectedDownloadSource 104 | ) 105 | .Skip(1) // Skip the initial load 106 | .Throttle(TimeSpan.FromMilliseconds(500)) // Debounce to avoid saving too frequently 107 | .SelectMany(_ => System.Reactive.Linq.Observable.FromAsync(() => AutoSaveSettingsAsync())) 108 | .Subscribe( 109 | _ => { }, 110 | ex => System.Console.WriteLine($"[SettingsViewModel] Auto-save subscription ERROR: {ex.Message}") 111 | ); 112 | } 113 | 114 | public string ApiBaseUrl 115 | { 116 | get => _apiBaseUrl; 117 | set => this.RaiseAndSetIfChanged(ref _apiBaseUrl, value); 118 | } 119 | 120 | public string ApiKey 121 | { 122 | get => _apiKey; 123 | set => this.RaiseAndSetIfChanged(ref _apiKey, value); 124 | } 125 | 126 | public string Model 127 | { 128 | get => _model; 129 | set 130 | { 131 | System.Console.WriteLine($"[Model Property] Setting Model from '{_model}' to '{value}'"); 132 | this.RaiseAndSetIfChanged(ref _model, value); 133 | } 134 | } 135 | 136 | public string DictionaryPath 137 | { 138 | get => _dictionaryPath; 139 | set => this.RaiseAndSetIfChanged(ref _dictionaryPath, value); 140 | } 141 | 142 | public string QuickQueryHotkey 143 | { 144 | get => _quickQueryHotkey; 145 | set => this.RaiseAndSetIfChanged(ref _quickQueryHotkey, value); 146 | } 147 | 148 | public string StatusMessage 149 | { 150 | get => _statusMessage; 151 | set => this.RaiseAndSetIfChanged(ref _statusMessage, value); 152 | } 153 | 154 | public bool IsApiKeyVisible 155 | { 156 | get => _isApiKeyVisible; 157 | set 158 | { 159 | this.RaiseAndSetIfChanged(ref _isApiKeyVisible, value); 160 | this.RaisePropertyChanged(nameof(ApiKeyToggleButtonText)); 161 | } 162 | } 163 | 164 | public string ApiKeyToggleButtonText => IsApiKeyVisible ? "Hide" : "Show"; 165 | 166 | public bool IsLoadingModels 167 | { 168 | get => _isLoadingModels; 169 | set => this.RaiseAndSetIfChanged(ref _isLoadingModels, value); 170 | } 171 | 172 | public ObservableCollection AvailableModels => _availableModels; 173 | 174 | public string SearchText 175 | { 176 | get => _searchText; 177 | set 178 | { 179 | this.RaiseAndSetIfChanged(ref _searchText, value); 180 | FilterCachedWords(); 181 | } 182 | } 183 | 184 | public bool IsLoadingCachedWords 185 | { 186 | get => _isLoadingCachedWords; 187 | set => this.RaiseAndSetIfChanged(ref _isLoadingCachedWords, value); 188 | } 189 | 190 | public ObservableCollection CachedWords => _cachedWords; 191 | public ObservableCollection FilteredCachedWords => _filteredCachedWords; 192 | 193 | public DictionaryDownloadSource SelectedDownloadSource 194 | { 195 | get => _selectedDownloadSource; 196 | set => this.RaiseAndSetIfChanged(ref _selectedDownloadSource, value); 197 | } 198 | 199 | public ObservableCollection AvailableDownloadSources => _availableDownloadSources; 200 | 201 | public bool HasAccessibilityPermissions 202 | { 203 | get => _hasAccessibilityPermissions; 204 | set => this.RaiseAndSetIfChanged(ref _hasAccessibilityPermissions, value); 205 | } 206 | 207 | public string PermissionStatusMessage 208 | { 209 | get => _permissionStatusMessage; 210 | set => this.RaiseAndSetIfChanged(ref _permissionStatusMessage, value); 211 | } 212 | 213 | public bool IsCheckingPermissions 214 | { 215 | get => _isCheckingPermissions; 216 | set => this.RaiseAndSetIfChanged(ref _isCheckingPermissions, value); 217 | } 218 | 219 | public bool IsAccessibilitySectionVisible 220 | { 221 | get => _isAccessibilitySectionVisible; 222 | private set => this.RaiseAndSetIfChanged(ref _isAccessibilitySectionVisible, value); 223 | } 224 | 225 | public ReactiveCommand DownloadDictionaryCommand { get; } 226 | public ReactiveCommand RefreshModelsCommand { get; } 227 | public ReactiveCommand ToggleApiKeyVisibilityCommand { get; } 228 | public ReactiveCommand RefreshCachedWordsCommand { get; } 229 | public ReactiveCommand RequestAccessibilityPermissionsCommand { get; } 230 | public ReactiveCommand CheckPermissionsCommand { get; } 231 | 232 | private void LoadSettings() 233 | { 234 | var settings = _settingsService.CurrentSettings; 235 | ApiBaseUrl = settings.ApiBaseUrl; 236 | ApiKey = settings.ApiKey; 237 | Model = settings.Model; 238 | DictionaryPath = settings.DictionaryPath; 239 | QuickQueryHotkey = settings.QuickQueryHotkey; 240 | SelectedDownloadSource = settings.DictionaryDownloadSource; 241 | } 242 | 243 | private async Task AutoSaveSettingsAsync() 244 | { 245 | try 246 | { 247 | var settings = new AppSettings 248 | { 249 | ApiBaseUrl = ApiBaseUrl, 250 | ApiKey = ApiKey, 251 | Model = Model, 252 | DictionaryPath = DictionaryPath, 253 | QuickQueryHotkey = QuickQueryHotkey, 254 | DictionaryDownloadSource = SelectedDownloadSource 255 | }; 256 | 257 | await _settingsService.SaveSettingsAsync(settings); 258 | } 259 | catch (Exception ex) 260 | { 261 | System.Console.WriteLine($"[SettingsViewModel] Auto-save ERROR: {ex.Message}"); 262 | } 263 | } 264 | 265 | private async Task DownloadDictionaryAsync() 266 | { 267 | System.Console.WriteLine("[SettingsViewModel] DownloadDictionaryAsync started"); 268 | try 269 | { 270 | if (string.IsNullOrEmpty(DictionaryPath)) 271 | { 272 | StatusMessage = "Please configure dictionary path first."; 273 | return; 274 | } 275 | 276 | System.Console.WriteLine("[SettingsViewModel] Creating services"); 277 | var resourceService = new DictionaryResourceService(); 278 | var downloadService = new DictionaryDownloadService(resourceService); 279 | 280 | System.Console.WriteLine("[SettingsViewModel] Creating download window"); 281 | var downloadWindow = new Views.DownloadProgressWindow(); 282 | 283 | System.Console.WriteLine("[SettingsViewModel] Showing download window"); 284 | downloadWindow.Show(); 285 | 286 | System.Console.WriteLine($"[SettingsViewModel] Starting download to path: {DictionaryPath}"); 287 | System.Console.WriteLine($"[SettingsViewModel] Using download source: {SelectedDownloadSource}"); 288 | await downloadService.EnsureDictionaryExistsAsync(DictionaryPath, SelectedDownloadSource, (message, progress) => 289 | { 290 | System.Console.WriteLine($"[SettingsViewModel] Progress callback: {message} ({progress}%)"); 291 | Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(() => 292 | { 293 | System.Console.WriteLine($"[SettingsViewModel] UI Thread updating: {message}"); 294 | downloadWindow.ViewModel.StatusMessage = message; 295 | downloadWindow.ViewModel.Progress = progress; 296 | downloadWindow.ViewModel.IsIndeterminate = progress < 10; 297 | 298 | if (progress >= 100) 299 | { 300 | System.Console.WriteLine("[SettingsViewModel] Download completed!"); 301 | downloadWindow.ViewModel.IsCompleted = true; 302 | StatusMessage = "Dictionary downloaded successfully!"; 303 | 304 | // Refresh the cached words list 305 | _ = Task.Run(async () => await RefreshCachedWordsAsync()); 306 | } 307 | }); 308 | }); 309 | System.Console.WriteLine("[SettingsViewModel] Download finished successfully"); 310 | } 311 | catch (Exception ex) 312 | { 313 | System.Console.WriteLine($"[SettingsViewModel] Download ERROR: {ex.GetType().Name}: {ex.Message}"); 314 | System.Console.WriteLine($"[SettingsViewModel] Stack trace: {ex.StackTrace}"); 315 | StatusMessage = $"Error downloading dictionary: {ex.Message}"; 316 | } 317 | } 318 | 319 | private async Task RefreshModelsAsync() 320 | { 321 | try 322 | { 323 | IsLoadingModels = true; 324 | StatusMessage = "Fetching available models..."; 325 | 326 | System.Console.WriteLine($"[RefreshModels] START - Current Model: '{Model}'"); 327 | 328 | // Save the current model selection before clearing 329 | var selectedModel = Model; 330 | System.Console.WriteLine($"[RefreshModels] Saved selected model: '{selectedModel}'"); 331 | 332 | var models = await _openAIService.GetAvailableModelsAsync(); 333 | 334 | System.Console.WriteLine($"[RefreshModels] Fetched {models.Count} models from API"); 335 | System.Console.WriteLine($"[RefreshModels] Before Clear - AvailableModels count: {_availableModels.Count}"); 336 | 337 | _availableModels.Clear(); 338 | 339 | System.Console.WriteLine($"[RefreshModels] After Clear - Current Model is now: '{Model}'"); 340 | 341 | // Add the saved model first if it's not in the list 342 | if (!string.IsNullOrEmpty(selectedModel) && !models.Contains(selectedModel)) 343 | { 344 | System.Console.WriteLine($"[RefreshModels] Adding saved model '{selectedModel}' to list (not in API response)"); 345 | _availableModels.Add(selectedModel); 346 | } 347 | else if (!string.IsNullOrEmpty(selectedModel)) 348 | { 349 | System.Console.WriteLine($"[RefreshModels] Saved model '{selectedModel}' is already in API response"); 350 | } 351 | 352 | _availableModels.AddRange(models); 353 | 354 | System.Console.WriteLine($"[RefreshModels] After AddRange - AvailableModels count: {_availableModels.Count}"); 355 | 356 | // Restore the selected model 357 | if (!string.IsNullOrEmpty(selectedModel)) 358 | { 359 | System.Console.WriteLine($"[RefreshModels] Restoring Model to: '{selectedModel}'"); 360 | Model = selectedModel; 361 | } 362 | 363 | System.Console.WriteLine($"[RefreshModels] END - Current Model: '{Model}'"); 364 | 365 | StatusMessage = models.Count > 0 366 | ? $"Loaded {models.Count} models successfully" 367 | : "No models found. Please check your API key and base URL."; 368 | } 369 | catch (Exception ex) 370 | { 371 | System.Console.WriteLine($"[RefreshModels] ERROR: {ex.Message}"); 372 | StatusMessage = $"Error fetching models: {ex.Message}"; 373 | 374 | // If fetching fails and we have a current model, ensure it's in the list 375 | if (!string.IsNullOrEmpty(Model) && !_availableModels.Contains(Model)) 376 | { 377 | System.Console.WriteLine($"[RefreshModels] Exception handler - Adding model '{Model}' to list"); 378 | _availableModels.Add(Model); 379 | } 380 | } 381 | finally 382 | { 383 | IsLoadingModels = false; 384 | System.Console.WriteLine($"[RefreshModels] FINALLY - IsLoadingModels set to false"); 385 | } 386 | } 387 | 388 | private void ToggleApiKeyVisibility() 389 | { 390 | IsApiKeyVisible = !IsApiKeyVisible; 391 | } 392 | 393 | private async Task RefreshCachedWordsAsync() 394 | { 395 | try 396 | { 397 | IsLoadingCachedWords = true; 398 | 399 | var words = await _dictionaryService.GetCachedWordsAsync(); 400 | 401 | _cachedWords.Clear(); 402 | _cachedWords.AddRange(words); 403 | 404 | FilterCachedWords(); 405 | } 406 | catch (Exception ex) 407 | { 408 | System.Console.WriteLine($"[SettingsViewModel] Error loading cached words: {ex.Message}"); 409 | } 410 | finally 411 | { 412 | IsLoadingCachedWords = false; 413 | } 414 | } 415 | 416 | private void FilterCachedWords() 417 | { 418 | _filteredCachedWords.Clear(); 419 | 420 | if (string.IsNullOrWhiteSpace(SearchText)) 421 | { 422 | _filteredCachedWords.AddRange(_cachedWords); 423 | } 424 | else 425 | { 426 | var filtered = _cachedWords.Where(word => 427 | word.Contains(SearchText, StringComparison.OrdinalIgnoreCase)); 428 | _filteredCachedWords.AddRange(filtered); 429 | } 430 | } 431 | 432 | private async Task CheckPermissionsAsync() 433 | { 434 | if (!IsAccessibilitySectionVisible || _hotkeyService == null) 435 | { 436 | HasAccessibilityPermissions = true; 437 | PermissionStatusMessage = string.Empty; 438 | IsCheckingPermissions = false; 439 | return; 440 | } 441 | 442 | try 443 | { 444 | IsCheckingPermissions = true; 445 | PermissionStatusMessage = "Checking accessibility permissions..."; 446 | 447 | // Give UI a chance to update 448 | await Task.Delay(100); 449 | 450 | var hasPermissions = await Task.Run(() => _hotkeyService.CheckAccessibilityPermissions()); 451 | HasAccessibilityPermissions = hasPermissions; 452 | 453 | PermissionStatusMessage = hasPermissions 454 | ? "Accessibility permissions granted" 455 | : "Accessibility permissions required for global hotkeys"; 456 | 457 | // If permissions are now granted, re-register the hotkey 458 | if (hasPermissions && _quickQueryService != null) 459 | { 460 | System.Console.WriteLine("[SettingsViewModel] Permissions granted, re-registering hotkey..."); 461 | _quickQueryService.ReregisterHotkey(); 462 | PermissionStatusMessage = "Accessibility permissions granted. Hotkey registered successfully."; 463 | } 464 | } 465 | catch (System.Exception ex) 466 | { 467 | PermissionStatusMessage = $"Error checking permissions: {ex.Message}"; 468 | System.Console.WriteLine($"[SettingsViewModel] Error checking accessibility permissions: {ex.Message}"); 469 | } 470 | finally 471 | { 472 | IsCheckingPermissions = false; 473 | System.Console.WriteLine($"[SettingsViewModel] Accessibility permissions: {HasAccessibilityPermissions}"); 474 | } 475 | } 476 | 477 | private void RequestAccessibilityPermissions() 478 | { 479 | if (!IsAccessibilitySectionVisible || _hotkeyService == null) 480 | { 481 | return; 482 | } 483 | 484 | PermissionStatusMessage = "Opening System Preferences. Grant access and then click Recheck Status."; 485 | _hotkeyService.RequestAccessibilityPermissions(); 486 | 487 | // Recheck after a short delay to update status 488 | Task.Delay(1000).ContinueWith(_ => 489 | { 490 | Avalonia.Threading.Dispatcher.UIThread.InvokeAsync(async () => 491 | { 492 | await CheckPermissionsAsync(); 493 | }); 494 | }); 495 | } 496 | } 497 | -------------------------------------------------------------------------------- /Aictionary/ViewModels/StatisticsViewModel.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Collections.Generic; 3 | using System.Collections.ObjectModel; 4 | using System.Globalization; 5 | using System.Linq; 6 | using System.Threading.Tasks; 7 | using Aictionary.Models; 8 | using Aictionary.Services; 9 | using ReactiveUI; 10 | 11 | namespace Aictionary.ViewModels; 12 | 13 | public class StatisticsViewModel : ViewModelBase 14 | { 15 | private readonly IQueryHistoryService _historyService; 16 | 17 | private bool _isLoading; 18 | private string _emptyMessage = string.Empty; 19 | 20 | public StatisticsViewModel(IQueryHistoryService historyService) 21 | { 22 | _historyService = historyService; 23 | } 24 | 25 | public ObservableCollection DailyGroups { get; } = new(); 26 | public ObservableCollection WeeklyGroups { get; } = new(); 27 | public ObservableCollection MonthlyGroups { get; } = new(); 28 | public ObservableCollection YearlyGroups { get; } = new(); 29 | 30 | public bool IsLoading 31 | { 32 | get => _isLoading; 33 | private set => this.RaiseAndSetIfChanged(ref _isLoading, value); 34 | } 35 | 36 | public string EmptyMessage 37 | { 38 | get => _emptyMessage; 39 | private set => this.RaiseAndSetIfChanged(ref _emptyMessage, value); 40 | } 41 | 42 | public async Task LoadAsync() 43 | { 44 | IsLoading = true; 45 | DailyGroups.Clear(); 46 | WeeklyGroups.Clear(); 47 | MonthlyGroups.Clear(); 48 | YearlyGroups.Clear(); 49 | EmptyMessage = string.Empty; 50 | 51 | try 52 | { 53 | var entries = await _historyService.GetEntriesAsync(); 54 | 55 | if (entries.Count == 0) 56 | { 57 | EmptyMessage = "No queries recorded yet."; 58 | return; 59 | } 60 | 61 | PopulateDailyGroups(entries); 62 | PopulateWeeklyGroups(entries); 63 | PopulateMonthlyGroups(entries); 64 | PopulateYearlyGroups(entries); 65 | } 66 | finally 67 | { 68 | IsLoading = false; 69 | } 70 | } 71 | 72 | private void PopulateDailyGroups(IReadOnlyList entries) 73 | { 74 | PopulateGroups( 75 | entries, 76 | DailyGroups, 77 | localTime => localTime.Date, 78 | start => start.ToString("MMMM dd, yyyy")); 79 | } 80 | 81 | private void PopulateWeeklyGroups(IReadOnlyList entries) 82 | { 83 | PopulateGroups( 84 | entries, 85 | WeeklyGroups, 86 | GetStartOfWeek, 87 | start => 88 | { 89 | var end = start.AddDays(6); 90 | return $"Week of {start:MMM dd} – {end:MMM dd, yyyy}"; 91 | }); 92 | } 93 | 94 | private void PopulateMonthlyGroups(IReadOnlyList entries) 95 | { 96 | PopulateGroups( 97 | entries, 98 | MonthlyGroups, 99 | localTime => new DateTime(localTime.Year, localTime.Month, 1), 100 | start => start.ToString("MMMM yyyy")); 101 | } 102 | 103 | private void PopulateYearlyGroups(IReadOnlyList entries) 104 | { 105 | PopulateGroups( 106 | entries, 107 | YearlyGroups, 108 | localTime => new DateTime(localTime.Year, 1, 1), 109 | start => start.ToString("yyyy")); 110 | } 111 | 112 | private void PopulateGroups( 113 | IReadOnlyList entries, 114 | ObservableCollection target, 115 | Func periodStartSelector, 116 | Func periodLabelSelector) 117 | { 118 | var grouped = entries 119 | .Select(entry => 120 | { 121 | var localTime = entry.QueriedAt.ToLocalTime(); 122 | var start = periodStartSelector(localTime); 123 | var label = periodLabelSelector(start); 124 | return new 125 | { 126 | Entry = entry, 127 | LocalTime = localTime, 128 | Start = start, 129 | Label = label 130 | }; 131 | }) 132 | .GroupBy(x => x.Start) 133 | .OrderByDescending(g => g.Key); 134 | 135 | foreach (var group in grouped) 136 | { 137 | var wordGroups = group 138 | .GroupBy(x => x.Entry.Word, StringComparer.OrdinalIgnoreCase) 139 | .Select(gw => 140 | { 141 | var mostRecent = gw.Max(x => x.LocalTime); 142 | var mostRecentEntry = gw 143 | .OrderByDescending(x => x.LocalTime) 144 | .ThenBy(x => x.Entry.Word, StringComparer.OrdinalIgnoreCase) 145 | .First(); 146 | return new StatisticsWordCountViewModel( 147 | mostRecentEntry.Entry.Word, 148 | gw.Count(), 149 | mostRecent, 150 | mostRecentEntry.Entry.ConciseDefinition); 151 | }) 152 | .OrderByDescending(x => x.Count) 153 | .ThenBy(x => x.Word, StringComparer.OrdinalIgnoreCase) 154 | .ToList(); 155 | 156 | target.Add(new StatisticsGroupItemViewModel( 157 | group.First().Label, 158 | group.Count(), 159 | wordGroups)); 160 | } 161 | } 162 | 163 | private static DateTime GetStartOfWeek(DateTime date) 164 | { 165 | var culture = CultureInfo.CurrentCulture; 166 | var diff = (7 + (date.DayOfWeek - culture.DateTimeFormat.FirstDayOfWeek)) % 7; 167 | return date.Date.AddDays(-diff); 168 | } 169 | 170 | public IEnumerable GetAllWords() 171 | { 172 | var allWords = new HashSet(StringComparer.OrdinalIgnoreCase); 173 | 174 | foreach (var group in DailyGroups) 175 | { 176 | foreach (var wordCount in group.WordCounts) 177 | { 178 | allWords.Add(wordCount.Word); 179 | } 180 | } 181 | 182 | foreach (var group in WeeklyGroups) 183 | { 184 | foreach (var wordCount in group.WordCounts) 185 | { 186 | allWords.Add(wordCount.Word); 187 | } 188 | } 189 | 190 | foreach (var group in MonthlyGroups) 191 | { 192 | foreach (var wordCount in group.WordCounts) 193 | { 194 | allWords.Add(wordCount.Word); 195 | } 196 | } 197 | 198 | foreach (var group in YearlyGroups) 199 | { 200 | foreach (var wordCount in group.WordCounts) 201 | { 202 | allWords.Add(wordCount.Word); 203 | } 204 | } 205 | 206 | return allWords.OrderBy(w => w, StringComparer.OrdinalIgnoreCase); 207 | } 208 | 209 | public async Task RemoveWordAsync(string word) 210 | { 211 | await _historyService.RemoveWordEntriesAsync(word); 212 | await LoadAsync(); 213 | } 214 | } 215 | 216 | public class StatisticsGroupItemViewModel 217 | { 218 | public StatisticsGroupItemViewModel(string label, int totalQueries, IList wordCounts) 219 | { 220 | Label = label; 221 | TotalQueries = totalQueries; 222 | WordCounts = new ReadOnlyCollection(wordCounts); 223 | } 224 | 225 | public string Label { get; } 226 | public int TotalQueries { get; } 227 | public IReadOnlyList WordCounts { get; } 228 | } 229 | 230 | public class StatisticsWordCountViewModel 231 | { 232 | public StatisticsWordCountViewModel(string word, int count, DateTime lastQueriedAtLocal, string? conciseDefinition = null) 233 | { 234 | Word = word; 235 | Count = count; 236 | LastQueriedAtLocal = lastQueriedAtLocal; 237 | ConciseDefinition = conciseDefinition; 238 | } 239 | 240 | public string Word { get; } 241 | public int Count { get; } 242 | public DateTime LastQueriedAtLocal { get; } 243 | public string? ConciseDefinition { get; } 244 | } 245 | -------------------------------------------------------------------------------- /Aictionary/ViewModels/ViewModelBase.cs: -------------------------------------------------------------------------------- 1 | using ReactiveUI; 2 | 3 | namespace Aictionary.ViewModels; 4 | 5 | public class ViewModelBase : ReactiveObject 6 | { 7 | } -------------------------------------------------------------------------------- /Aictionary/Views/DownloadProgressWindow.axaml: -------------------------------------------------------------------------------- 1 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | 24 | 25 | 30 | 31 | 32 | 36 | 37 | 64 | 70 | 71 | 72 | 73 | 78 | 79 | 80 | 81 | 82 | 61 | 62 | 63 | 64 | 65 | 66 | 67 | 68 | 69 | 70 | 72 | 73 | 76 | 84 | 85 | 86 | 91 | 95 | 98 | 99 | 100 | 102 | 103 | 104 | 106 | 107 | 108 | 109 | 110 | 111 | 112 | 113 | 114 | 115 | 116 | 117 | 118 | 119 | 120 | 121 | 122 | 123 | 124 | 125 | 126 | 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | 137 | 138 | 139 | 140 | 141 | -------------------------------------------------------------------------------- /Aictionary/Views/StatisticsWindow.axaml.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.Diagnostics; 3 | using System.IO; 4 | using System.Linq; 5 | using System.Runtime.InteropServices; 6 | using Aictionary.ViewModels; 7 | using Avalonia.Controls; 8 | using Avalonia.Interactivity; 9 | using Avalonia.Platform.Storage; 10 | 11 | namespace Aictionary.Views; 12 | 13 | public partial class StatisticsWindow : Window 14 | { 15 | private bool _isInitialized; 16 | 17 | public StatisticsWindow() 18 | { 19 | InitializeComponent(); 20 | } 21 | 22 | protected override async void OnOpened(EventArgs e) 23 | { 24 | base.OnOpened(e); 25 | 26 | if (_isInitialized) 27 | { 28 | return; 29 | } 30 | 31 | if (DataContext is StatisticsViewModel viewModel) 32 | { 33 | try 34 | { 35 | await viewModel.LoadAsync(); 36 | } 37 | catch (Exception ex) 38 | { 39 | System.Console.WriteLine($"[StatisticsWindow] LoadAsync ERROR: {ex.Message}"); 40 | } 41 | } 42 | 43 | _isInitialized = true; 44 | } 45 | 46 | private async void ExportButton_Click(object? sender, RoutedEventArgs e) 47 | { 48 | if (DataContext is not StatisticsViewModel viewModel) 49 | return; 50 | 51 | var storageProvider = StorageProvider; 52 | 53 | if (!storageProvider.CanSave) 54 | return; 55 | 56 | var file = await storageProvider.SaveFilePickerAsync(new FilePickerSaveOptions 57 | { 58 | Title = "Export Words to TXT", 59 | SuggestedFileName = "words.txt", 60 | FileTypeChoices = new[] 61 | { 62 | new FilePickerFileType("Text File") 63 | { 64 | Patterns = new[] { "*.txt" } 65 | } 66 | } 67 | }); 68 | 69 | if (file == null) 70 | return; 71 | 72 | try 73 | { 74 | var words = viewModel.GetAllWords(); 75 | var content = string.Join(Environment.NewLine, words); 76 | var filePath = file.Path.LocalPath; 77 | await File.WriteAllTextAsync(filePath, content); 78 | 79 | // Open the file location in Finder/Explorer 80 | OpenFileLocation(filePath); 81 | } 82 | catch (Exception ex) 83 | { 84 | System.Console.WriteLine($"[StatisticsWindow] Export ERROR: {ex.Message}"); 85 | } 86 | } 87 | 88 | private void OpenFileLocation(string filePath) 89 | { 90 | try 91 | { 92 | var directory = Path.GetDirectoryName(filePath); 93 | if (string.IsNullOrEmpty(directory)) 94 | return; 95 | 96 | if (RuntimeInformation.IsOSPlatform(OSPlatform.Windows)) 97 | { 98 | Process.Start(new ProcessStartInfo 99 | { 100 | FileName = "explorer.exe", 101 | Arguments = $"/select,\"{filePath}\"", 102 | UseShellExecute = true 103 | }); 104 | } 105 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.OSX)) 106 | { 107 | Process.Start(new ProcessStartInfo 108 | { 109 | FileName = "open", 110 | Arguments = $"-R \"{filePath}\"", 111 | UseShellExecute = true 112 | }); 113 | } 114 | else if (RuntimeInformation.IsOSPlatform(OSPlatform.Linux)) 115 | { 116 | Process.Start(new ProcessStartInfo 117 | { 118 | FileName = "xdg-open", 119 | Arguments = directory, 120 | UseShellExecute = true 121 | }); 122 | } 123 | } 124 | catch (Exception ex) 125 | { 126 | System.Console.WriteLine($"[StatisticsWindow] Open location ERROR: {ex.Message}"); 127 | } 128 | } 129 | 130 | private async void DeleteWordButton_Click(object? sender, RoutedEventArgs e) 131 | { 132 | if (sender is not Button button || button.Tag is not string word) 133 | return; 134 | 135 | if (DataContext is not StatisticsViewModel viewModel) 136 | return; 137 | 138 | try 139 | { 140 | await viewModel.RemoveWordAsync(word); 141 | } 142 | catch (Exception ex) 143 | { 144 | System.Console.WriteLine($"[StatisticsWindow] Delete word ERROR: {ex.Message}"); 145 | } 146 | } 147 | } 148 | -------------------------------------------------------------------------------- /Aictionary/app.manifest: -------------------------------------------------------------------------------- 1 |  2 | 3 | 6 | 7 | 8 | 9 | 10 | 13 | 14 | 15 | 16 | 17 | 18 | 19 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Aictionary 2 | 3 | 快速且异常好用的词典 App,而且,它甚至跨平台! 4 | 5 | image 6 | 7 | 8 | --- 9 | 10 | ## 功能特性 11 | 12 | - **最详细的中文解释** - 不同于传统词典的粗浅释义,本项目的中文释义都非常详细、易懂 13 | - **快速查询单词** - 基于词频统计的前 25000 个单词已经被默认下载,覆盖绝大部分生词情景 14 | - **大语言模型驱动** - 如果你真的遇到了词库里没有的词,也可以调用大模型生成一份词义 15 | - **跨平台支持** - 基于 Avalonia 框架,支持 Windows、macOS 和 Linux 16 | 17 | 除此之外,我们还围绕日常使用体验做了不少优化: 18 | 19 | - **实时响应**:查词窗口轻量、打开即用,支持键盘快捷键快速查询、复制和刷新; 20 | - **本地缓存**:自动保存历史查询记录,可离线查看,并可按需刷新保证释义始终同步; 21 | - **多形态发布**:提供自包含与框架依赖两套版本,macOS 用户还可直接使用打包好的 DMG。 22 | 23 | ## 下载与安装 24 | 25 | 构建完成后,所有产物都会落在 `artifacts/` 目录下,也可在发布页直接获取: 26 | 27 | | 平台 | 自包含产物 | 体积更小的框架依赖产物 | 28 | | --- | --- | --- | 29 | | macOS | `artifacts/macos/Aictionary.app`
`artifacts/macos/Aictionary.dmg` | `artifacts/macos-framework-dependent/Aictionary.app` | 30 | | Windows | `artifacts/windows/Aictionary-win-x64` | `artifacts/windows-framework-dependent/Aictionary-win-x64` | 31 | 32 | - macOS 建议直接打开 `Aictionary.dmg`,将 App 拖入 `Applications` 即可; 33 | - Windows 可按是否安装 .NET 运行时选择对应文件夹; 34 | - Linux 用户可使用源码编译运行(参考下文“开发构建”)。 35 | 36 | > 初次使用别忘了在设置页配置 OpenAI API Key,当本地词库缺少词条时会调用大模型补充释义。 37 | 38 | ## 使用提示 39 | 40 | - **键盘操作**:涵盖复制查询、刷新词库等快捷键,阅读英文资料时切换成本极低; 41 | - **缓存管理**:设置页可查看、刷新缓存词条,方便构建个人生词本; 42 | - **中文释义**:大模型生成的解释包含语义说明、例句与常见搭配,更贴合技术、学术场景。 43 | 44 | ## 技术栈 45 | 46 | - **.NET 8.0** 47 | - **Avalonia 11.2.7** 48 | - **ReactiveUI** 49 | 50 | ## 开发构建 51 | 52 | 项目使用 [NUKE](https://nuke.build/) 编排构建流程,运行以下命令即可生成全部发行包: 53 | 54 | ```bash 55 | dotnet run --project build/build.csproj 56 | ``` 57 | 58 | 执行后会在 `artifacts/` 目录生成: 59 | 60 | - macOS 自包含 App、框架依赖 App 以及 DMG 安装包; 61 | - Windows 自包含与框架依赖版本。 62 | 63 | 如需自定义流程,可阅读 `build/Build.cs` 中各个 target 的实现。 64 | 65 | ## 反馈与贡献 66 | 67 | - 欢迎通过 Issue 反馈问题、分享改进想法; 68 | - PR 亦非常欢迎,请附上截图或说明,便于快速审阅; 69 | - 希望扩展词库、适配更多平台或接入新的大模型?一起来讨论吧! 70 | -------------------------------------------------------------------------------- /build/BUILD.md: -------------------------------------------------------------------------------- 1 | # Aictionary Build Documentation 2 | 3 | This project uses [NUKE](https://nuke.build/) for automated build and publishing. 4 | 5 | ## Prerequisites 6 | 7 | - .NET 8.0 SDK or later 8 | - NUKE global tool (will be installed automatically if missing) 9 | 10 | ## Available Build Targets 11 | 12 | The build system supports the following targets: 13 | 14 | - **Clean**: Cleans the artifacts directory 15 | - **Restore**: Restores NuGet packages 16 | - **Compile**: Compiles the solution 17 | - **PublishWindows**: Publishes Windows x64 build 18 | - **PublishMacOS**: Publishes macOS ARM64 build 19 | - **Publish**: Publishes both Windows and macOS builds (default target) 20 | 21 | ## Quick Start 22 | 23 | ```bash 24 | # From the project root directory 25 | ./build.sh 26 | 27 | # Or on Windows 28 | .\build.ps1 29 | ``` 30 | 31 | ## How to Run 32 | 33 | ### Option 1: Using build scripts (Recommended) 34 | 35 | #### On macOS/Linux: 36 | ```bash 37 | ./build.sh 38 | 39 | # Publish only Windows 40 | ./build.sh --target PublishWindows 41 | 42 | # Publish only macOS 43 | ./build.sh --target PublishMacOS 44 | ``` 45 | 46 | #### On Windows: 47 | ```powershell 48 | .\build.ps1 49 | 50 | # Publish only Windows 51 | .\build.ps1 --target PublishWindows 52 | 53 | # Publish only macOS 54 | .\build.ps1 --target PublishMacOS 55 | ``` 56 | 57 | ### Option 2: Using dotnet run 58 | 59 | Navigate to the repository root and run: 60 | 61 | ```bash 62 | # Publish all platforms (default) 63 | dotnet run --project build/build.csproj 64 | 65 | # Publish only Windows 66 | dotnet run --project build/build.csproj --target PublishWindows 67 | 68 | # Publish only macOS 69 | dotnet run --project build/build.csproj --target PublishMacOS 70 | 71 | # Just compile without publishing 72 | dotnet run --project build/build.csproj --target Compile 73 | ``` 74 | 75 | ## Output 76 | 77 | Published artifacts will be located in the `artifacts` directory: 78 | 79 | - **Windows**: `artifacts/windows/Aictionary-win-x64/` 80 | - **macOS**: `artifacts/macos/Aictionary.app/` 81 | 82 | The macOS build includes: 83 | - Proper app bundle structure 84 | - App icon (AppIcon.icns) 85 | - Info.plist configuration 86 | 87 | ## Build Configuration 88 | 89 | You can specify the configuration (Debug/Release) using the `--configuration` parameter: 90 | 91 | ```bash 92 | dotnet run --project build/build.csproj --configuration Debug 93 | ``` 94 | 95 | Default configuration is **Release**. 96 | 97 | ## Notes 98 | 99 | - The Windows build is self-contained and targets x64 architecture 100 | - The macOS build is self-contained and targets ARM64 (Apple Silicon) 101 | - Both builds use single-file publishing for easier distribution 102 | - Trimming is disabled to avoid potential issues with reflection-heavy code 103 | 104 | ## Troubleshooting 105 | 106 | If you encounter issues: 107 | 108 | 1. Ensure .NET 8.0 SDK is installed: `dotnet --version` 109 | 2. Clean and restore: `dotnet run --project build/build.csproj --target Clean` 110 | 3. Check the build output for specific error messages 111 | 112 | For more information about NUKE, visit: https://nuke.build/ 113 | -------------------------------------------------------------------------------- /build/Build.cs: -------------------------------------------------------------------------------- 1 | using System; 2 | using System.IO; 3 | using Nuke.Common; 4 | using Nuke.Common.IO; 5 | using Nuke.Common.ProjectModel; 6 | using Nuke.Common.Tooling; 7 | using Nuke.Common.Tools.DotNet; 8 | using static Nuke.Common.Tools.DotNet.DotNetTasks; 9 | 10 | class Build : NukeBuild 11 | { 12 | public static int Main() => Execute(x => x.Publish); 13 | 14 | [Parameter("Configuration to build - Default is 'Release'")] 15 | readonly string Configuration = "Release"; 16 | 17 | AbsolutePath SourceDirectory => RootDirectory / "Aictionary"; 18 | AbsolutePath ArtifactsDirectory => RootDirectory / "artifacts"; 19 | AbsolutePath WindowsArtifactDirectory => ArtifactsDirectory / "windows"; 20 | AbsolutePath WindowsFrameworkArtifactDirectory => ArtifactsDirectory / "windows-framework-dependent"; 21 | AbsolutePath MacOSArtifactDirectory => ArtifactsDirectory / "macos"; 22 | AbsolutePath MacOSFrameworkArtifactDirectory => ArtifactsDirectory / "macos-framework-dependent"; 23 | AbsolutePath MacOSDmgFile => MacOSArtifactDirectory / "Aictionary.dmg"; 24 | AbsolutePath ProjectFile => SourceDirectory / "Aictionary.csproj"; 25 | 26 | Target Clean => _ => _ 27 | .Before(Restore) 28 | .Executes(() => 29 | { 30 | if (Directory.Exists(ArtifactsDirectory)) 31 | { 32 | try 33 | { 34 | Directory.Delete(ArtifactsDirectory, true); 35 | } 36 | catch (IOException) 37 | { 38 | var backupDirectory = ArtifactsDirectory + $"_{DateTime.Now:yyyyMMddHHmmssfff}"; 39 | Directory.Move(ArtifactsDirectory, backupDirectory); 40 | Serilog.Log.Warning("Could not delete artifacts directory, moved to {BackupDirectory}", backupDirectory); 41 | } 42 | catch (UnauthorizedAccessException) 43 | { 44 | var backupDirectory = ArtifactsDirectory + $"_{DateTime.Now:yyyyMMddHHmmssfff}"; 45 | Directory.Move(ArtifactsDirectory, backupDirectory); 46 | Serilog.Log.Warning("Could not delete artifacts directory due to access restrictions, moved to {BackupDirectory}", backupDirectory); 47 | } 48 | } 49 | 50 | Directory.CreateDirectory(ArtifactsDirectory); 51 | }); 52 | 53 | Target Restore => _ => _ 54 | .DependsOn(Clean) 55 | .Executes(() => 56 | { 57 | DotNetRestore(s => s 58 | .SetProjectFile(ProjectFile)); 59 | }); 60 | 61 | Target Compile => _ => _ 62 | .DependsOn(Restore) 63 | .Executes(() => 64 | { 65 | DotNetBuild(s => s 66 | .SetProjectFile(ProjectFile) 67 | .SetConfiguration(Configuration) 68 | .EnableNoRestore()); 69 | }); 70 | 71 | Target PublishWindows => _ => _ 72 | .DependsOn(Clean) 73 | .Executes(() => 74 | { 75 | DotNetPublish(s => s 76 | .SetProject(ProjectFile) 77 | .SetConfiguration(Configuration) 78 | .SetRuntime("win-x64") 79 | .SetSelfContained(true) 80 | .SetPublishSingleFile(true) 81 | .SetPublishTrimmed(false) 82 | .SetOutput(WindowsArtifactDirectory / "Aictionary-win-x64")); 83 | 84 | Serilog.Log.Information($"Windows artifact published to: {WindowsArtifactDirectory}"); 85 | }); 86 | 87 | Target PublishMacOS => _ => _ 88 | .DependsOn(Clean) 89 | .Executes(() => 90 | { 91 | var publishOutput = MacOSArtifactDirectory / "publish"; 92 | var appBundlePath = MacOSArtifactDirectory / "Aictionary.app"; 93 | var contentsPath = appBundlePath / "Contents"; 94 | var macOsPath = contentsPath / "MacOS"; 95 | 96 | if (Directory.Exists(publishOutput)) 97 | Directory.Delete(publishOutput, true); 98 | 99 | DotNetPublish(s => s 100 | .SetProject(ProjectFile) 101 | .SetConfiguration(Configuration) 102 | .SetRuntime("osx-arm64") 103 | .SetSelfContained(true) 104 | .SetPublishSingleFile(true) 105 | .SetPublishTrimmed(false) 106 | .SetOutput(publishOutput)); 107 | 108 | if (Directory.Exists(appBundlePath)) 109 | Directory.Delete(appBundlePath, true); 110 | 111 | Directory.CreateDirectory(macOsPath); 112 | CopyDirectoryContents(publishOutput, macOsPath); 113 | Directory.Delete(publishOutput, true); 114 | 115 | // Copy Info.plist and icon 116 | var infoSource = SourceDirectory / "Info.plist"; 117 | var iconSource = SourceDirectory / "Assets" / "AppIcon.icns"; 118 | var resourcesDir = contentsPath / "Resources"; 119 | var infoTarget = contentsPath / "Info.plist"; 120 | 121 | Directory.CreateDirectory(resourcesDir); 122 | if (File.Exists(infoSource)) 123 | { 124 | File.Copy(infoSource, infoTarget, true); 125 | } 126 | if (File.Exists(iconSource)) 127 | { 128 | File.Copy(iconSource, resourcesDir / "AppIcon.icns", true); 129 | } 130 | 131 | Serilog.Log.Information($"macOS artifact published to: {MacOSArtifactDirectory}"); 132 | }); 133 | 134 | Target PublishMacOSFrameworkDependent => _ => _ 135 | .DependsOn(Clean) 136 | .Executes(() => 137 | { 138 | var publishOutput = MacOSFrameworkArtifactDirectory / "publish"; 139 | var appBundlePath = MacOSFrameworkArtifactDirectory / "Aictionary.app"; 140 | var contentsPath = appBundlePath / "Contents"; 141 | var macOsPath = contentsPath / "MacOS"; 142 | 143 | if (Directory.Exists(publishOutput)) 144 | Directory.Delete(publishOutput, true); 145 | 146 | DotNetPublish(s => s 147 | .SetProject(ProjectFile) 148 | .SetConfiguration(Configuration) 149 | .SetRuntime("osx-arm64") 150 | .DisableSelfContained() 151 | .DisablePublishSingleFile() 152 | .SetPublishTrimmed(false) 153 | .SetOutput(publishOutput)); 154 | 155 | if (Directory.Exists(appBundlePath)) 156 | Directory.Delete(appBundlePath, true); 157 | 158 | Directory.CreateDirectory(macOsPath); 159 | CopyDirectoryContents(publishOutput, macOsPath); 160 | Directory.Delete(publishOutput, true); 161 | 162 | var infoSource = SourceDirectory / "Info.plist"; 163 | var iconSource = SourceDirectory / "Assets" / "AppIcon.icns"; 164 | var resourcesDir = contentsPath / "Resources"; 165 | var infoTarget = contentsPath / "Info.plist"; 166 | 167 | Directory.CreateDirectory(resourcesDir); 168 | if (File.Exists(infoSource)) 169 | { 170 | File.Copy(infoSource, infoTarget, true); 171 | } 172 | if (File.Exists(iconSource)) 173 | { 174 | File.Copy(iconSource, resourcesDir / "AppIcon.icns", true); 175 | } 176 | 177 | Serilog.Log.Information($"macOS framework-dependent artifact published to: {MacOSFrameworkArtifactDirectory}"); 178 | }); 179 | 180 | Target Publish => _ => _ 181 | .DependsOn(PublishWindows, 182 | PublishMacOS, 183 | PublishWindowsFrameworkDependent, 184 | PublishMacOSFrameworkDependent, 185 | CreateMacOSDmg) 186 | .Executes(() => 187 | { 188 | Serilog.Log.Information("All platforms published successfully!"); 189 | Serilog.Log.Information($"Artifacts location: {ArtifactsDirectory}"); 190 | }); 191 | 192 | Target PublishWindowsFrameworkDependent => _ => _ 193 | .DependsOn(Clean) 194 | .Executes(() => 195 | { 196 | var outputDirectory = WindowsFrameworkArtifactDirectory / "Aictionary-win-x64"; 197 | 198 | DotNetPublish(s => s 199 | .SetProject(ProjectFile) 200 | .SetConfiguration(Configuration) 201 | .SetRuntime("win-x64") 202 | .DisableSelfContained() 203 | .DisablePublishSingleFile() 204 | .SetPublishTrimmed(false) 205 | .SetOutput(outputDirectory)); 206 | 207 | Serilog.Log.Information($"Windows framework-dependent artifact published to: {WindowsFrameworkArtifactDirectory}"); 208 | }); 209 | 210 | Target CreateMacOSDmg => _ => _ 211 | .DependsOn(PublishMacOS) 212 | .Executes(() => 213 | { 214 | if (File.Exists(MacOSDmgFile)) 215 | { 216 | File.Delete(MacOSDmgFile); 217 | } 218 | 219 | var appBundlePath = MacOSArtifactDirectory / "Aictionary.app"; 220 | 221 | var arguments = string.Join(" ", new[] 222 | { 223 | "--volname \"Aictionary\"", 224 | "--window-pos 200 120", 225 | "--window-size 800 400", 226 | "--icon-size 128", 227 | "--app-drop-link 600 185", 228 | $"\"{MacOSDmgFile}\"", 229 | $"\"{appBundlePath}\"" 230 | }); 231 | 232 | ProcessTasks.StartProcess("create-dmg", arguments) 233 | .AssertZeroExitCode(); 234 | 235 | Serilog.Log.Information($"macOS DMG created at: {MacOSDmgFile}"); 236 | }); 237 | 238 | static void CopyDirectoryContents(string source, string destination) 239 | { 240 | Directory.CreateDirectory(destination); 241 | 242 | foreach (var file in Directory.GetFiles(source)) 243 | { 244 | var targetFile = Path.Combine(destination, Path.GetFileName(file)); 245 | File.Copy(file, targetFile, true); 246 | } 247 | 248 | foreach (var directory in Directory.GetDirectories(source)) 249 | { 250 | var targetDirectory = Path.Combine(destination, Path.GetFileName(directory)); 251 | CopyDirectoryContents(directory, targetDirectory); 252 | } 253 | } 254 | } 255 | -------------------------------------------------------------------------------- /build/build.csproj: -------------------------------------------------------------------------------- 1 |  2 | 3 | 4 | Exe 5 | net8.0 6 | enable 7 | enable 8 | 1 9 | 10 | 11 | 12 | 13 | 14 | 15 | 16 | -------------------------------------------------------------------------------- /build/build.ps1: -------------------------------------------------------------------------------- 1 | [CmdletBinding()] 2 | Param( 3 | [Parameter(Position=0,Mandatory=$false,ValueFromRemainingArguments=$true)] 4 | [string[]]$BuildArguments 5 | ) 6 | 7 | $ScriptDir = Split-Path $MyInvocation.MyCommand.Path -Parent 8 | 9 | & dotnet run --project "$ScriptDir/build.csproj" -- $BuildArguments 10 | 11 | exit $LASTEXITCODE 12 | -------------------------------------------------------------------------------- /build/build.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | 3 | set -eo pipefail 4 | 5 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 6 | 7 | dotnet run --project "$SCRIPT_DIR/build.csproj" -- "$@" 8 | --------------------------------------------------------------------------------