├── .gitattributes ├── LICENSE ├── PowerAgent ├── Space │ └── PowerAgent.txt ├── __pycache__ │ └── constants.cpython-313.pyc ├── assets │ └── settings.png ├── constants.py ├── core │ ├── __pycache__ │ │ ├── __init__.cpython-313.pyc │ │ ├── autostart.cpython-313.pyc │ │ ├── command_executor.cpython-313.pyc │ │ ├── config.cpython-313.pyc │ │ ├── gui_controller.cpython-313.pyc │ │ ├── keyboard_controller.cpython-313.pyc │ │ ├── logging_config.cpython-313.pyc │ │ ├── stream_handler.cpython-313.pyc │ │ ├── worker_utils.cpython-313.pyc │ │ └── workers.cpython-313.pyc │ ├── autostart.py │ ├── command_executor.py │ ├── config.py │ ├── gui_controller.py │ ├── logging_config.py │ ├── stream_handler.py │ ├── worker_utils.py │ └── workers.py ├── gui │ ├── __pycache__ │ │ ├── __init__.cpython-313.pyc │ │ ├── main_window.cpython-313.pyc │ │ ├── main_window_handlers.cpython-313.pyc │ │ ├── main_window_state.cpython-313.pyc │ │ ├── main_window_updates.cpython-313.pyc │ │ ├── main_window_workers.cpython-313.pyc │ │ ├── palette.cpython-313.pyc │ │ ├── settings_dialog.cpython-313.pyc │ │ ├── stylesheets.cpython-313.pyc │ │ └── ui_components.cpython-313.pyc │ ├── main_window.py │ ├── main_window_handlers.py │ ├── main_window_state.py │ ├── main_window_updates.py │ ├── main_window_workers.py │ ├── palette.py │ ├── settings_dialog.py │ ├── stylesheets.py │ └── ui_components.py ├── logs │ └── power_agent.log └── main.py └── README.md /.gitattributes: -------------------------------------------------------------------------------- 1 | # Auto detect text files and perform LF normalization 2 | * text=auto 3 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | Apache License 2 | Version 2.0, January 2004 3 | http://www.apache.org/licenses/ 4 | 5 | TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION 6 | 7 | 1. Definitions. 8 | 9 | "License" shall mean the terms and conditions for use, reproduction, 10 | and distribution as defined by Sections 1 through 9 of this document. 11 | 12 | "Licensor" shall mean the copyright owner or entity authorized by 13 | the copyright owner that is granting the License. 14 | 15 | "Legal Entity" shall mean the union of the acting entity and all 16 | other entities that control, are controlled by, or are under common 17 | control with that entity. For the purposes of this definition, 18 | "control" means (i) the power, direct or indirect, to cause the 19 | direction or management of such entity, whether by contract or 20 | otherwise, or (ii) ownership of fifty percent (50%) or more of the 21 | outstanding shares, or (iii) beneficial ownership of such entity. 22 | 23 | "You" (or "Your") shall mean an individual or Legal Entity 24 | exercising permissions granted by this License. 25 | 26 | "Source" form shall mean the preferred form for making modifications, 27 | including but not limited to software source code, documentation 28 | source, and configuration files. 29 | 30 | "Object" form shall mean any form resulting from mechanical 31 | transformation or translation of a Source form, including but 32 | not limited to compiled object code, generated documentation, 33 | and conversions to other media types. 34 | 35 | "Work" shall mean the work of authorship, whether in Source or 36 | Object form, made available under the License, as indicated by a 37 | copyright notice that is included in or attached to the work 38 | (an example is provided in the Appendix below). 39 | 40 | "Derivative Works" shall mean any work, whether in Source or Object 41 | form, that is based on (or derived from) the Work and for which the 42 | editorial revisions, annotations, elaborations, or other modifications 43 | represent, as a whole, an original work of authorship. For the purposes 44 | of this License, Derivative Works shall not include works that remain 45 | separable from, or merely link (or bind by name) to the interfaces of, 46 | the Work and Derivative Works thereof. 47 | 48 | "Contribution" shall mean any work of authorship, including 49 | the original version of the Work and any modifications or additions 50 | to that Work or Derivative Works thereof, that is intentionally 51 | submitted to Licensor for inclusion in the Work by the copyright owner 52 | or by an individual or Legal Entity authorized to submit on behalf of 53 | the copyright owner. For the purposes of this definition, "submitted" 54 | means any form of electronic, verbal, or written communication sent 55 | to the Licensor or its representatives, including but not limited to 56 | communication on electronic mailing lists, source code control systems, 57 | and issue tracking systems that are managed by, or on behalf of, the 58 | Licensor for the purpose of discussing and improving the Work, but 59 | excluding communication that is conspicuously marked or otherwise 60 | designated in writing by the copyright owner as "Not a Contribution." 61 | 62 | "Contributor" shall mean Licensor and any individual or Legal Entity 63 | on behalf of whom a Contribution has been received by Licensor and 64 | subsequently incorporated within the Work. 65 | 66 | 2. Grant of Copyright License. Subject to the terms and conditions of 67 | this License, each Contributor hereby grants to You a perpetual, 68 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 69 | copyright license to reproduce, prepare Derivative Works of, 70 | publicly display, publicly perform, sublicense, and distribute the 71 | Work and such Derivative Works in Source or Object form. 72 | 73 | 3. Grant of Patent License. Subject to the terms and conditions of 74 | this License, each Contributor hereby grants to You a perpetual, 75 | worldwide, non-exclusive, no-charge, royalty-free, irrevocable 76 | (except as stated in this section) patent license to make, have made, 77 | use, offer to sell, sell, import, and otherwise transfer the Work, 78 | where such license applies only to those patent claims licensable 79 | by such Contributor that are necessarily infringed by their 80 | Contribution(s) alone or by combination of their Contribution(s) 81 | with the Work to which such Contribution(s) was submitted. If You 82 | institute patent litigation against any entity (including a 83 | cross-claim or counterclaim in a lawsuit) alleging that the Work 84 | or a Contribution incorporated within the Work constitutes direct 85 | or contributory patent infringement, then any patent licenses 86 | granted to You under this License for that Work shall terminate 87 | as of the date such litigation is filed. 88 | 89 | 4. Redistribution. You may reproduce and distribute copies of the 90 | Work or Derivative Works thereof in any medium, with or without 91 | modifications, and in Source or Object form, provided that You 92 | meet the following conditions: 93 | 94 | (a) You must give any other recipients of the Work or 95 | Derivative Works a copy of this License; and 96 | 97 | (b) You must cause any modified files to carry prominent notices 98 | stating that You changed the files; and 99 | 100 | (c) You must retain, in the Source form of any Derivative Works 101 | that You distribute, all copyright, patent, trademark, and 102 | attribution notices from the Source form of the Work, 103 | excluding those notices that do not pertain to any part of 104 | the Derivative Works; and 105 | 106 | (d) If the Work includes a "NOTICE" text file as part of its 107 | distribution, then any Derivative Works that You distribute must 108 | include a readable copy of the attribution notices contained 109 | within such NOTICE file, excluding those notices that do not 110 | pertain to any part of the Derivative Works, in at least one 111 | of the following places: within a NOTICE text file distributed 112 | as part of the Derivative Works; within the Source form or 113 | documentation, if provided along with the Derivative Works; or, 114 | within a display generated by the Derivative Works, if and 115 | wherever such third-party notices normally appear. The contents 116 | of the NOTICE file are for informational purposes only and 117 | do not modify the License. You may add Your own attribution 118 | notices within Derivative Works that You distribute, alongside 119 | or as an addendum to the NOTICE text from the Work, provided 120 | that such additional attribution notices cannot be construed 121 | as modifying the License. 122 | 123 | You may add Your own copyright statement to Your modifications and 124 | may provide additional or different license terms and conditions 125 | for use, reproduction, or distribution of Your modifications, or 126 | for any such Derivative Works as a whole, provided Your use, 127 | reproduction, and distribution of the Work otherwise complies with 128 | the conditions stated in this License. 129 | 130 | 5. Submission of Contributions. Unless You explicitly state otherwise, 131 | any Contribution intentionally submitted for inclusion in the Work 132 | by You to the Licensor shall be under the terms and conditions of 133 | this License, without any additional terms or conditions. 134 | Notwithstanding the above, nothing herein shall supersede or modify 135 | the terms of any separate license agreement you may have executed 136 | with Licensor regarding such Contributions. 137 | 138 | 6. Trademarks. This License does not grant permission to use the trade 139 | names, trademarks, service marks, or product names of the Licensor, 140 | except as required for reasonable and customary use in describing the 141 | origin of the Work and reproducing the content of the NOTICE file. 142 | 143 | 7. Disclaimer of Warranty. Unless required by applicable law or 144 | agreed to in writing, Licensor provides the Work (and each 145 | Contributor provides its Contributions) on an "AS IS" BASIS, 146 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or 147 | implied, including, without limitation, any warranties or conditions 148 | of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A 149 | PARTICULAR PURPOSE. You are solely responsible for determining the 150 | appropriateness of using or redistributing the Work and assume any 151 | risks associated with Your exercise of permissions under this License. 152 | 153 | 8. Limitation of Liability. In no event and under no legal theory, 154 | whether in tort (including negligence), contract, or otherwise, 155 | unless required by applicable law (such as deliberate and grossly 156 | negligent acts) or agreed to in writing, shall any Contributor be 157 | liable to You for damages, including any direct, indirect, special, 158 | incidental, or consequential damages of any character arising as a 159 | result of this License or out of the use or inability to use the 160 | Work (including but not limited to damages for loss of goodwill, 161 | work stoppage, computer failure or malfunction, or any and all 162 | other commercial damages or losses), even if such Contributor 163 | has been advised of the possibility of such damages. 164 | 165 | 9. Accepting Warranty or Additional Liability. While redistributing 166 | the Work or Derivative Works thereof, You may choose to offer, 167 | and charge a fee for, acceptance of support, warranty, indemnity, 168 | or other liability obligations and/or rights consistent with this 169 | License. However, in accepting such obligations, You may act only 170 | on Your own behalf and on Your sole responsibility, not on behalf 171 | of any other Contributor, and only if You agree to indemnify, 172 | defend, and hold each Contributor harmless for any liability 173 | incurred by, or claims asserted against, such Contributor by reason 174 | of your accepting any such warranty or additional liability. 175 | 176 | END OF TERMS AND CONDITIONS 177 | 178 | APPENDIX: How to apply the Apache License to your work. 179 | 180 | To apply the Apache License to your work, attach the following 181 | boilerplate notice, with the fields enclosed by brackets "[]" 182 | replaced with your own identifying information. (Don't include 183 | the brackets!) The text should be enclosed in the appropriate 184 | comment syntax for the file format. We also recommend that a 185 | file or class name and description of purpose be included on the 186 | same "printed page" as the copyright notice for easier 187 | identification within third-party archives. 188 | 189 | Copyright [yyyy] [name of copyright owner] 190 | 191 | Licensed under the Apache License, Version 2.0 (the "License"); 192 | you may not use this file except in compliance with the License. 193 | You may obtain a copy of the License at 194 | 195 | http://www.apache.org/licenses/LICENSE-2.0 196 | 197 | Unless required by applicable law or agreed to in writing, software 198 | distributed under the License is distributed on an "AS IS" BASIS, 199 | WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. 200 | See the License for the specific language governing permissions and 201 | limitations under the License. 202 | -------------------------------------------------------------------------------- /PowerAgent/Space/PowerAgent.txt: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/Space/PowerAgent.txt -------------------------------------------------------------------------------- /PowerAgent/__pycache__/constants.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/__pycache__/constants.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/assets/settings.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/assets/settings.png -------------------------------------------------------------------------------- /PowerAgent/constants.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/constants.py 3 | # (No structural changes needed, ensure 'ai_command' color exists) 4 | # ----------------------------------------------------------------------- 5 | # constants.py 6 | # -*- coding: utf-8 -*- 7 | 8 | from PySide6.QtGui import QColor 9 | 10 | # --- Application Constants --- 11 | APP_NAME = "PowerAgent" 12 | ORG_NAME = "YourOrgName" # Change if desired 13 | SETTINGS_APP_NAME = "PowerAgent" # Used for QSettings and auto-start identifiers 14 | 15 | # --- UI Colors (Static Definitions) --- 16 | 17 | # Dark Theme Colors 18 | _DARK = { 19 | # ============================================================= # 20 | # <<< User 改为绿色,Model 改为浅蓝色 >>> 21 | "user": QColor(144, 238, 144), # LightGreen 22 | "model": QColor(173, 216, 230), # lightblue 23 | # ============================================================= # 24 | "system": QColor("lightGray"), 25 | "error": QColor("red"), 26 | "help": QColor(173, 216, 230), # lightblue 27 | "prompt": QColor("magenta"), 28 | # --- Added color for keyboard actions --- 29 | "keyboard_action": QColor(255, 165, 0), # orange 30 | # --- Added background color for keyboard actions --- 31 | "keyboard_action_bg": QColor(65, 65, 65), # Slightly lighter dark gray 32 | # <<< ADDED: Color for AI command echo in chat >>> 33 | "ai_command": QColor(0, 255, 255), # Cyan <<<< ENSURE THIS EXISTS 34 | # --- Deprecated colors --- 35 | "ai_cmd_echo": QColor(255, 165, 0), # orange (DEPRECATED) 36 | "cli_cmd_echo": QColor(173, 216, 230), # lightblue (DEPRECATED) 37 | # --- CLI Colors --- 38 | "cli_output": QColor("white"), 39 | "cli_error": QColor(255, 100, 100), # light red 40 | "cli_bg": QColor(40, 40, 40), # dark gray 41 | # --- Other UI Colors --- 42 | "status_label": QColor("lightgrey"), 43 | "cwd_label": QColor("lightgrey"), 44 | "border": QColor(60, 60, 60), 45 | "text_main": QColor(235, 235, 235), 46 | # --- Added color for timestamp --- 47 | "timestamp_color": QColor(160, 160, 160), # Gray 48 | } 49 | 50 | # Light Theme Colors 51 | _LIGHT = { 52 | # ============================================================= # 53 | # <<< User 改为绿色,Model 改为浅蓝色 (选择对比度合适的颜色) >>> 54 | "user": QColor("green"), # Dark Green for contrast 55 | "model": QColor("blue"), # Blue for contrast 56 | # ============================================================= # 57 | "system": QColor(80, 80, 80), # Darker Gray for better contrast 58 | "error": QColor(200, 0, 0), # Dark Red 59 | "help": QColor(0, 0, 150), # Dark Blue 60 | "prompt": QColor(150, 0, 150), # Dark Magenta 61 | # --- Added color for keyboard actions --- 62 | "keyboard_action": QColor(200, 100, 0), # Darker Orange/Brown 63 | # --- Added background color for keyboard actions --- 64 | "keyboard_action_bg": QColor(225, 225, 225), # Light gray background 65 | # <<< ADDED: Color for AI command echo in chat >>> 66 | "ai_command": QColor(0, 139, 139), # Dark Cyan <<<< ENSURE THIS EXISTS 67 | # --- Deprecated colors --- 68 | "ai_cmd_echo": QColor(200, 100, 0), # Darker Orange (DEPRECATED) 69 | "cli_cmd_echo": QColor(0, 0, 150), # Dark Blue (DEPRECATED) 70 | # --- CLI Colors --- 71 | "cli_output": QColor("black"), 72 | "cli_error": QColor(200, 0, 0), # Dark Red (same as error color) 73 | "cli_bg": QColor(245, 245, 245), # Very light gray 74 | # --- Other UI Colors --- 75 | "status_label": QColor("darkslategrey"), 76 | "cwd_label": QColor("darkslategrey"), 77 | "border": QColor(190, 190, 190), 78 | "text_main": QColor(0, 0, 0), 79 | # --- Added color for timestamp --- 80 | "timestamp_color": QColor(105, 105, 105), # DimGray 81 | } 82 | 83 | def get_color(color_name: str, theme: str = "dark") -> QColor: 84 | """ 85 | Gets the QColor for a specific element based on the current theme. 86 | 87 | Args: 88 | color_name: The name of the color element (e.g., 'user', 'cli_bg', 'keyboard_action', 'ai_command'). 89 | theme: The current theme ('dark' or 'light'). 90 | 91 | Returns: 92 | The corresponding QColor. Defaults to the theme's main text color if name/theme invalid. 93 | """ 94 | theme_dict = _LIGHT if theme == "light" else _DARK 95 | # Define the default text color based on the theme 96 | default_text_color = _LIGHT.get("text_main", QColor("black")) if theme == "light" else _DARK.get("text_main", QColor("white")) 97 | # Get the color, falling back to the default text color if the name isn't found 98 | return theme_dict.get(color_name, default_text_color) -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/autostart.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/autostart.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/command_executor.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/command_executor.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/config.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/config.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/gui_controller.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/gui_controller.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/keyboard_controller.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/keyboard_controller.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/logging_config.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/logging_config.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/stream_handler.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/stream_handler.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/worker_utils.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/worker_utils.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/__pycache__/workers.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/core/__pycache__/workers.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/core/autostart.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/core/autostart.py 3 | # (No changes needed in this file for this feature) 4 | # ----------------------------------------------------------------------- 5 | # core/autostart.py 6 | # -*- coding: utf-8 -*- 7 | 8 | import sys 9 | import os 10 | import platform 11 | import ctypes # For checking admin rights on Windows 12 | from PySide6.QtCore import QSettings, QStandardPaths, QCoreApplication 13 | 14 | # Import constants needed for paths/names 15 | from constants import SETTINGS_APP_NAME, ORG_NAME 16 | 17 | # --- Auto-Startup Management --- 18 | def is_admin(): 19 | """Check if the script is running with administrator privileges on Windows.""" 20 | if platform.system() == "Windows": 21 | try: 22 | return ctypes.windll.shell32.IsUserAnAdmin() != 0 23 | except Exception as e: 24 | print(f"Error checking admin status: {e}") 25 | return False 26 | # On non-Windows, this check is typically not needed for user-level autostart 27 | return False 28 | 29 | def get_script_path(): 30 | """Gets the absolute path to the currently running script or executable.""" 31 | if getattr(sys, 'frozen', False): # Check if running as a bundled executable (PyInstaller) 32 | # sys.executable is the path to the bundled .exe or app 33 | return sys.executable 34 | else: # Running as a script 35 | # sys.argv[0] is generally the path of the script that was initially run 36 | return os.path.abspath(sys.argv[0]) 37 | 38 | def get_python_executable(): 39 | """Gets the path to the pythonw executable if possible, otherwise python.""" 40 | # sys.executable is the path to the Python interpreter running the script 41 | python_exe = sys.executable 42 | if platform.system() == "Windows" and not getattr(sys, 'frozen', False): 43 | # Prefer pythonw.exe for scripts to avoid console window on startup 44 | pythonw_exe = python_exe.replace("python.exe", "pythonw.exe") 45 | if os.path.exists(pythonw_exe): 46 | return pythonw_exe 47 | return python_exe # Fallback for non-windows, bundled apps, or if pythonw not found 48 | 49 | def set_auto_startup(enable): 50 | """Enable or disable auto-startup for the application.""" 51 | # Ensure app/org names are set for QSettings/paths if not already 52 | if not QCoreApplication.organizationName(): 53 | QCoreApplication.setOrganizationName(ORG_NAME) 54 | if not QCoreApplication.applicationName(): 55 | QCoreApplication.setApplicationName(SETTINGS_APP_NAME) 56 | 57 | app_name = SETTINGS_APP_NAME # Use the constant 58 | script_path = get_script_path() 59 | 60 | # Determine the command to run 61 | if getattr(sys, 'frozen', False): # Bundled app 62 | run_command = f'"{script_path}"' 63 | else: # Running as script 64 | python_exe = get_python_executable() 65 | run_command = f'"{python_exe}" "{script_path}"' 66 | 67 | 68 | print(f"Attempting to set auto-startup to: {enable}") 69 | print(f" App Name: {app_name}") 70 | print(f" Command: {run_command}") 71 | print(f" Platform: {platform.system()}") 72 | 73 | 74 | if platform.system() == "Windows": 75 | # HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run 76 | # This key usually doesn't require admin rights 77 | settings_key = r"HKEY_CURRENT_USER\Software\Microsoft\Windows\CurrentVersion\Run" 78 | # Use NativeFormat for registry access 79 | settings = QSettings(settings_key, QSettings.Format.NativeFormat) 80 | try: 81 | current_value = settings.value(app_name) 82 | if enable: 83 | if current_value != run_command: 84 | print(f"Writing registry key: {settings_key}\\{app_name}") 85 | settings.setValue(app_name, run_command) 86 | settings.sync() # Ensure changes are written immediately 87 | # Verification 88 | if settings.value(app_name) == run_command: 89 | print("Auto-startup enabled successfully (Registry).") 90 | else: 91 | print("Verification failed: Could not enable auto-startup (Registry write error?).") 92 | else: 93 | print(f"Registry key already exists with correct value.") 94 | else: # Disable 95 | if settings.contains(app_name): 96 | print(f"Removing registry key: {settings_key}\\{app_name}") 97 | settings.remove(app_name) 98 | settings.sync() # Ensure changes are written immediately 99 | # Verification 100 | if not settings.contains(app_name): 101 | print("Auto-startup disabled successfully (Registry).") 102 | else: 103 | print("Verification failed: Could not disable auto-startup (Registry remove error?).") 104 | else: 105 | print(f"Registry key not found for removal: {settings_key}\\{app_name}") 106 | 107 | except Exception as e: 108 | print(f"Error updating registry for auto-startup: {e}") 109 | # No need to suggest admin rights here as HKCU usually doesn't need it. 110 | # If it fails, it's likely a different issue (permissions policy, antivirus). 111 | 112 | elif platform.system() == "Linux": 113 | # ~/.config/autostart/AppName.desktop 114 | try: 115 | autostart_dir = os.path.join(QStandardPaths.writableLocation(QStandardPaths.StandardLocation.ConfigLocation), "autostart") 116 | desktop_file_path = os.path.join(autostart_dir, f"{app_name}.desktop") 117 | 118 | if enable: 119 | os.makedirs(autostart_dir, exist_ok=True) # Ensure directory exists 120 | print(f"Creating/updating desktop entry: {desktop_file_path}") 121 | # Use generic AppName and Comment, or fetch from constants if defined 122 | desktop_entry = f"""[Desktop Entry] 123 | Type=Application 124 | Name={app_name} 125 | Exec={run_command} 126 | Comment=Start {app_name} on login 127 | Terminal=false 128 | X-GNOME-Autostart-enabled=true 129 | """ 130 | # Try finding an icon (optional) 131 | icon_path = os.path.join(os.path.dirname(script_path), "assets", "icon.png") # Example path 132 | if os.path.exists(icon_path): 133 | desktop_entry += f"Icon={icon_path}\n" 134 | 135 | with open(desktop_file_path, 'w', encoding='utf-8') as f: 136 | f.write(desktop_entry) 137 | # Ensure correct permissions (usually not needed, but safe) 138 | os.chmod(desktop_file_path, 0o644) # rw-r--r-- 139 | print("Auto-startup enabled (Linux .desktop file created/updated).") 140 | else: # Disable 141 | if os.path.exists(desktop_file_path): 142 | print(f"Removing desktop entry: {desktop_file_path}") 143 | os.remove(desktop_file_path) 144 | print("Auto-startup disabled (Linux .desktop file removed).") 145 | else: 146 | print(f"Autostart file not found for removal: {desktop_file_path}") 147 | except Exception as e: 148 | print(f"Error managing Linux auto-startup file: {e}") 149 | 150 | elif platform.system() == "Darwin": # macOS 151 | # ~/Library/LaunchAgents/com.YourOrgName.AppName.plist 152 | try: 153 | launch_agents_dir = os.path.expanduser("~/Library/LaunchAgents") 154 | # Use constants for label and filename consistency 155 | plist_label = f"com.{ORG_NAME}.{app_name}" 156 | plist_filename = f"{plist_label}.plist" 157 | plist_file_path = os.path.join(launch_agents_dir, plist_filename) 158 | 159 | 160 | if enable: 161 | os.makedirs(launch_agents_dir, exist_ok=True) # Ensure directory exists 162 | print(f"Creating/updating LaunchAgent file: {plist_file_path}") 163 | 164 | # Split the command into program and arguments for the plist 165 | if getattr(sys, 'frozen', False): # Bundled app 166 | program_args = [script_path] 167 | else: # Running as script 168 | program_args = [get_python_executable(), script_path] 169 | 170 | # Create plist content 171 | plist_content = f""" 172 | 173 | 174 | 175 | Label 176 | {plist_label} 177 | ProgramArguments 178 | 179 | {''.join(f'{arg}' for arg in program_args)} 180 | 181 | RunAtLoad 182 | 183 | KeepAlive 184 | 185 | 186 | WorkingDirectory 187 | {os.path.dirname(script_path)} 188 | 189 | 195 | 196 | 197 | """ 198 | with open(plist_file_path, 'w', encoding='utf-8') as f: 199 | f.write(plist_content) 200 | # Ensure correct permissions 201 | os.chmod(plist_file_path, 0o644) # rw-r--r-- 202 | print("Auto-startup enabled (macOS LaunchAgent created/updated).") 203 | print("Note: May require logout/login or manual `launchctl load` to take effect immediately.") 204 | else: # Disable 205 | if os.path.exists(plist_file_path): 206 | print(f"Removing LaunchAgent file: {plist_file_path}") 207 | os.remove(plist_file_path) 208 | print("Auto-startup disabled (macOS LaunchAgent removed).") 209 | print("Note: May require logout/login or manual `launchctl unload` to take effect immediately.") 210 | 211 | else: 212 | print(f"LaunchAgent file not found for removal: {plist_file_path}") 213 | except Exception as e: 214 | print(f"Error managing macOS LaunchAgent file: {e}") 215 | 216 | else: 217 | print(f"Auto-startup not implemented for platform: {platform.system()}") -------------------------------------------------------------------------------- /PowerAgent/core/command_executor.py: -------------------------------------------------------------------------------- 1 | # core/command_executor.py 2 | # -*- coding: utf-8 -*- 3 | 4 | """ 5 | Handles the execution of shell commands, including 'cd'. 6 | Reads stdout/stderr *after* the process completes (NO LIVE STREAMING). 7 | """ 8 | 9 | import subprocess 10 | import os 11 | import platform 12 | import base64 13 | import traceback 14 | import time 15 | import io 16 | import logging # Import logging 17 | from typing import Callable, List 18 | from PySide6.QtCore import QThread, Signal, QObject 19 | 20 | # Import utility using relative path 21 | try: 22 | from .worker_utils import decode_output 23 | except ImportError: 24 | logging.error("Failed to import .worker_utils in command_executor.", exc_info=True) 25 | def decode_output(b): return repr(b) # Fallback 26 | 27 | # --- Get Logger --- 28 | logger = logging.getLogger(__name__) 29 | 30 | def execute_command_streamed( # Function name kept for compatibility 31 | command: str, 32 | cwd: str, 33 | stop_flag_func: Callable[[], bool], 34 | output_signal: Signal, 35 | error_signal: Signal, 36 | directory_changed_signal: Signal, 37 | is_manual_command: bool 38 | ) -> tuple[str, int | None]: 39 | """ 40 | Executes a shell command, handling 'cd' directly. Reads output after completion. Logs the process. 41 | 42 | Args: 43 | command: The command string to execute. 44 | cwd: The current working directory for execution. 45 | stop_flag_func: A callable that returns True if execution should stop. 46 | output_signal: Signal to emit stdout bytes. (Emits: bytes) 47 | error_signal: Signal to emit stderr bytes or error messages. (Emits: bytes) 48 | directory_changed_signal: Signal to emit when 'cd' changes directory. (Emits: str, bool) 49 | is_manual_command: Passed to directory_changed_signal to indicate source. 50 | 51 | Returns: 52 | tuple: (final_cwd, exit_code) 53 | """ 54 | command_source = "Manual" if is_manual_command else "AI" 55 | logger.info(f"Executing command ({command_source}): '{command[:100]}{'...' if len(command)>100 else ''}' in CWD: {cwd}") 56 | 57 | current_cwd = cwd 58 | exit_code = None 59 | process: subprocess.Popen | None = None 60 | process_pid = -1 61 | 62 | # --- Signal Emission Helpers with Logging --- 63 | def _emit_error(message: str): 64 | logger.debug(f"Emitting error signal: {message}") 65 | try: error_signal.emit(f"Error: {message}".encode('utf-8')) 66 | except RuntimeError: logger.warning("Cannot emit error signal, target likely deleted.") 67 | except Exception as e: logger.error("Unexpected error emitting error signal.", exc_info=True) 68 | 69 | def _emit_output_bytes(b_message: bytes, is_stderr: bool): 70 | target_signal = error_signal if is_stderr else output_signal 71 | signal_name = "error_signal (stderr)" if is_stderr else "output_signal (stdout)" 72 | logger.debug(f"Emitting {signal_name} with {len(b_message)} bytes.") 73 | try: target_signal.emit(b_message) 74 | except RuntimeError: logger.warning(f"Cannot emit {signal_name}, target likely deleted.") 75 | except Exception as e: logger.error(f"Unexpected error emitting {signal_name}.", exc_info=True) 76 | 77 | def _emit_dir_changed(new_dir: str): 78 | logger.debug(f"Emitting directory_changed signal: NewDir={new_dir}, IsManual={is_manual_command}") 79 | try: directory_changed_signal.emit(new_dir, is_manual_command) 80 | except RuntimeError: logger.warning("Cannot emit directory_changed signal, target likely deleted.") 81 | except Exception as e: logger.error("Unexpected error emitting directory_changed signal.", exc_info=True) 82 | # --- End Signal Helpers --- 83 | 84 | 85 | # --- Handle 'cd' command directly --- 86 | command = command.strip() 87 | if command.lower().startswith('cd '): 88 | logger.info("Handling 'cd' command directly.") 89 | original_dir = current_cwd 90 | try: 91 | path_part = command[3:].strip() 92 | # Handle quotes 93 | if len(path_part) >= 2 and ((path_part.startswith('"') and path_part.endswith('"')) or \ 94 | (path_part.startswith("'") and path_part.endswith("'"))): 95 | path_part = path_part[1:-1] 96 | logger.debug(f"'cd': Path part after removing quotes: '{path_part}'") 97 | 98 | if not path_part or path_part == '~': 99 | target_dir = os.path.expanduser("~") 100 | logger.debug(f"'cd': Targeting home directory: {target_dir}") 101 | else: 102 | target_dir_expanded = os.path.expanduser(path_part) 103 | if not os.path.isabs(target_dir_expanded): 104 | target_dir = os.path.abspath(os.path.join(current_cwd, target_dir_expanded)) 105 | logger.debug(f"'cd': Relative path '{target_dir_expanded}' resolved to absolute: {target_dir}") 106 | else: 107 | target_dir = target_dir_expanded 108 | logger.debug(f"'cd': Path part '{path_part}' resolved to absolute: {target_dir}") 109 | 110 | target_dir = os.path.normpath(target_dir) 111 | logger.debug(f"'cd': Final normalized target directory: {target_dir}") 112 | 113 | if os.path.isdir(target_dir): 114 | current_cwd = target_dir 115 | logger.info(f"'cd': Directory successfully changed to: {current_cwd}") 116 | # Check stop flag before emitting signal 117 | if not stop_flag_func(): _emit_dir_changed(current_cwd) 118 | else: logger.warning("'cd': Directory changed, but stop flag set before signal emission.") 119 | else: 120 | error_msg = f"Directory not found: '{target_dir}' (Resolved from '{path_part}')" 121 | logger.error(f"'cd' failed: {error_msg}"); _emit_error(error_msg) 122 | except Exception as e: 123 | logger.error(f"Error processing 'cd' command: {e}", exc_info=True) 124 | _emit_error(f"Error processing 'cd' command: {e}") 125 | logger.debug("'cd' command handling finished.") 126 | return current_cwd, None # Exit code is None for 'cd' 127 | # --- End 'cd' handling --- 128 | 129 | # --- Pre-Execution Checks --- 130 | if stop_flag_func(): 131 | logger.warning("Execution skipped: Stop flag was set before start.") 132 | return current_cwd, exit_code # Return current CWD, no exit code 133 | if not command: 134 | logger.info("Empty command received, nothing to execute.") 135 | return current_cwd, exit_code 136 | 137 | # --- Execute other commands using Popen --- 138 | stdout_data = b"" 139 | stderr_data = b"" 140 | try: 141 | run_args = None; use_shell = False; creationflags = 0; preexec_fn = None 142 | os_name = platform.system() 143 | logger.debug(f"Preparing command for OS: {os_name}") 144 | 145 | # --- Prepare command arguments --- 146 | if os_name == "Windows": 147 | try: 148 | logger.debug("Using PowerShell with EncodedCommand.") 149 | # Construct the PowerShell command to handle errors and suppress progress 150 | ps_command_safe_no_progress = f"$ProgressPreference = 'SilentlyContinue'; try {{ {command} }} catch {{ Write-Error $_; exit 1 }}" 151 | logger.debug(f"PowerShell Script (Original): {ps_command_safe_no_progress[:200]}...") 152 | encoded_bytes = ps_command_safe_no_progress.encode('utf-16le') 153 | encoded_ps_command = base64.b64encode(encoded_bytes).decode('ascii') 154 | run_args = ["powershell.exe", "-NoProfile", "-ExecutionPolicy", "Bypass", "-EncodedCommand", encoded_ps_command] 155 | creationflags = subprocess.CREATE_NO_WINDOW | subprocess.CREATE_NEW_PROCESS_GROUP 156 | logger.debug(f"PowerShell Encoded Command (first 100 chars): {encoded_ps_command[:100]}...") 157 | except Exception as encode_err: 158 | logger.error(f"Error encoding command for PowerShell: {encode_err}", exc_info=True) 159 | _emit_error(f"Error encoding command for PowerShell: {encode_err}"); return current_cwd, None 160 | else: # Linux / macOS 161 | shell_path = os.environ.get("SHELL", "/bin/sh") 162 | logger.debug(f"Using Shell: {shell_path}") 163 | run_args = [shell_path, "-c", command] 164 | try: preexec_fn = os.setsid # Try to start in new session to allow group kill 165 | except AttributeError: logger.warning("os.setsid not available on this platform."); preexec_fn = None 166 | # --- End argument preparation --- 167 | 168 | if run_args is None: logger.error("Could not determine run arguments for subprocess."); return current_cwd, None 169 | if stop_flag_func(): logger.warning("Execution skipped: Stop flag set before Popen."); return current_cwd, exit_code 170 | 171 | logger.info(f"Executing Popen: {run_args}") 172 | process = subprocess.Popen( 173 | run_args, stdout=subprocess.PIPE, stderr=subprocess.PIPE, cwd=current_cwd, shell=use_shell, 174 | creationflags=creationflags, preexec_fn=preexec_fn 175 | ) 176 | process_pid = process.pid 177 | logger.info(f"Process started with PID: {process_pid}") 178 | 179 | # --- Wait for process completion OR stop signal --- 180 | termination_initiated = False 181 | while process.poll() is None: 182 | if stop_flag_func(): 183 | logger.warning(f"Stop signal received for PID {process_pid}. Terminating process...") 184 | termination_initiated = True 185 | # --- Termination Logic --- 186 | try: 187 | if platform.system() == "Windows": 188 | kill_cmd = ['taskkill', '/PID', str(process_pid), '/T', '/F']; kill_flags = subprocess.CREATE_NO_WINDOW 189 | logger.debug(f"Attempting Windows termination: {kill_cmd}") 190 | result = subprocess.run(kill_cmd, check=False, capture_output=True, creationflags=kill_flags, timeout=5) 191 | if result.returncode == 0: logger.info(f"Process {process_pid} tree terminated successfully via taskkill.") 192 | else: logger.warning(f"Taskkill may have failed for PID {process_pid}. ExitCode: {result.returncode}, Stderr: {result.stderr.decode(errors='ignore')}") 193 | else: # Linux/macOS 194 | import signal 195 | pgid_to_kill = -1 196 | try: 197 | pgid_to_kill = os.getpgid(process_pid) 198 | logger.debug(f"Attempting Linux/macOS termination: Sending SIGKILL to process group {pgid_to_kill}.") 199 | os.killpg(pgid_to_kill, signal.SIGKILL) 200 | logger.info(f"Sent SIGKILL to process group {pgid_to_kill}.") 201 | except ProcessLookupError: 202 | logger.warning(f"Process {process_pid} not found for getpgid/killpg, likely finished or already killed.") 203 | except Exception as kill_err: 204 | logger.error(f"Error during killpg for PGID {pgid_to_kill} (PID {process_pid}). Falling back to kill PID.", exc_info=True) 205 | try: 206 | logger.debug(f"Fallback: Sending SIGKILL to process PID {process_pid}.") 207 | os.kill(process_pid, signal.SIGKILL) 208 | logger.info(f"Sent SIGKILL to process PID {process_pid}.") 209 | except ProcessLookupError: logger.warning(f"Fallback kill failed: Process {process_pid} not found.") 210 | except Exception as final_kill_err: logger.error(f"Fallback kill for PID {process_pid} also failed.", exc_info=True) 211 | except ProcessLookupError: logger.warning(f"Process {process_pid} not found during termination attempt.") 212 | except Exception as e: logger.error(f"Error during process termination logic for PID {process_pid}.", exc_info=True) 213 | # --- End Termination Logic --- 214 | exit_code = -999 # Use a specific code for manual stop 215 | break # Exit the waiting loop 216 | 217 | # Use QThread.msleep for better Qt integration if available, else time.sleep 218 | try: QThread.msleep(100) 219 | except: time.sleep(0.1) 220 | 221 | # --- Process Finished or Terminated --- 222 | if exit_code is None: # If not stopped manually, get the final exit code 223 | exit_code = process.poll() 224 | logger.info(f"Process PID {process_pid} finished naturally. Exit code: {exit_code}") 225 | else: 226 | logger.info(f"Process PID {process_pid} was terminated manually. Exit code set to: {exit_code}") 227 | 228 | 229 | # --- Read Remaining Output AFTER process exit --- 230 | logger.debug(f"Reading final stdout/stderr for PID {process_pid}...") 231 | try: 232 | # Use communicate() for safety and to avoid potential deadlocks 233 | stdout_data, stderr_data = process.communicate(timeout=10) # Increased timeout slightly 234 | logger.debug(f"Communicate successful. Stdout bytes: {len(stdout_data)}, Stderr bytes: {len(stderr_data)}") 235 | except subprocess.TimeoutExpired: 236 | logger.warning(f"Timeout expired during communicate() for PID {process_pid}. Killing process.") 237 | process.kill() 238 | stdout_data, stderr_data = process.communicate() # Try again after kill 239 | logger.debug(f"Communicate after kill. Stdout bytes: {len(stdout_data)}, Stderr bytes: {len(stderr_data)}") 240 | except Exception as comm_err: 241 | logger.error(f"Error during process.communicate() for PID {process_pid}.", exc_info=True) 242 | # Attempt manual reads as fallback 243 | try: 244 | logger.debug(f"Attempting fallback read() for PID {process_pid}...") 245 | if process.stdout: stdout_data = process.stdout.read() 246 | if process.stderr: stderr_data = process.stderr.read() 247 | logger.debug(f"Fallback read. Stdout bytes: {len(stdout_data)}, Stderr bytes: {len(stderr_data)}") 248 | except Exception as read_err: 249 | logger.error(f"Error during fallback read() for PID {process_pid}.", exc_info=True) 250 | 251 | # --- Emit Final Output --- 252 | if stdout_data: 253 | logger.info(f"Emitting final stdout ({len(stdout_data)} bytes) for PID {process_pid}.") 254 | _emit_output_bytes(stdout_data, is_stderr=False) 255 | if stderr_data: 256 | logger.info(f"Emitting final stderr ({len(stderr_data)} bytes) for PID {process_pid}.") 257 | _emit_output_bytes(stderr_data, is_stderr=True) 258 | 259 | # --- Check Final Return Code and Emit Error if Needed --- 260 | if exit_code is not None and exit_code != 0 and exit_code != -999: 261 | logger.warning(f"Command PID {process_pid} exited with non-zero code: {exit_code}.") 262 | emitted_any_stderr = bool(stderr_data) 263 | # Decode stderr for checking if exit code message is already present 264 | stderr_str_for_check = decode_output(stderr_data) if emitted_any_stderr else "" 265 | # Avoid duplicate error messages 266 | exit_code_str = str(exit_code) 267 | # Check more robustly if the exit code is part of the error message (e.g., "exited with code 1") 268 | if not emitted_any_stderr or (exit_code_str not in stderr_str_for_check and f"code {exit_code_str}" not in stderr_str_for_check.lower()): 269 | exit_msg = f"Command exited with code: {exit_code}" 270 | logger.info(f"Emitting explicit exit code error message for PID {process_pid}: {exit_msg}") 271 | _emit_error(exit_msg) 272 | else: 273 | logger.info(f"Non-zero exit code message for PID {process_pid} suppressed as stderr likely contained relevant info.") 274 | 275 | except FileNotFoundError as fnf_err: 276 | cmd_name = run_args[0] if run_args else "N/A"; fnf_msg = f"Command or execution shell not found: '{cmd_name}'. {fnf_err}"; logger.error(fnf_msg); _emit_error(fnf_msg); return current_cwd, None 277 | except PermissionError as pe: 278 | perm_msg = f"Permission denied executing command: {pe}"; logger.error(perm_msg, exc_info=False); _emit_error(perm_msg); return current_cwd, None 279 | except Exception as exec_err: 280 | pid_info = f"PID {process_pid}" if process_pid != -1 else "PID N/A" 281 | logger.critical(f"Unhandled error during command execution ({pid_info}).", exc_info=True) 282 | _emit_error(f"Unexpected execution error: {exec_err}"); exit_code = -1 # Indicate error 283 | finally: 284 | # --- Final Process Cleanup --- 285 | if process and process.poll() is None: 286 | logger.warning(f"Process PID {process_pid} still running in finally block. Attempting final kill.") 287 | try: process.kill(); process.wait(timeout=1) 288 | except Exception as final_kill_err: logger.error(f"Error during final process kill/wait for PID {process_pid}.", exc_info=True) 289 | 290 | # Close pipes explicitly (less critical now with communicate, but can be good practice) 291 | if process: 292 | if process.stdout: 293 | try: process.stdout.close() 294 | except Exception as e: logger.debug(f"Error closing stdout for PID {process_pid}: {e}") 295 | if process.stderr: 296 | try: process.stderr.close() 297 | except Exception as e: logger.debug(f"Error closing stderr for PID {process_pid}: {e}") 298 | # Final wait attempt 299 | if process: 300 | try: 301 | final_exit_code = process.wait(timeout=0.5) 302 | if exit_code is None: exit_code = final_exit_code # Update exit code if not set yet 303 | logger.debug(f"Final process wait completed for PID {process_pid}. Exit code: {final_exit_code}.") 304 | except subprocess.TimeoutExpired: logger.warning(f"Process PID {process_pid} did not exit cleanly after final wait timeout.") 305 | except Exception as wait_err: logger.error(f"Error during final process wait for PID {process_pid}.", exc_info=True) 306 | 307 | logger.info(f"Finished executing command logic for PID {process_pid} ('{command[:50]}{'...' if len(command)>50 else ''}'). Final exit code: {exit_code}") 308 | 309 | return current_cwd, exit_code -------------------------------------------------------------------------------- /PowerAgent/core/config.py: -------------------------------------------------------------------------------- 1 | # core/config.py 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import sys 6 | import configparser 7 | import logging # Import logging 8 | from PySide6.QtCore import QSettings, QCoreApplication 9 | 10 | # Import constants and potentially other core modules if needed later 11 | from constants import ORG_NAME, SETTINGS_APP_NAME 12 | # Import autostart function because save_config calls it 13 | from .autostart import set_auto_startup 14 | 15 | # --- Get Logger --- 16 | # Get the logger for this specific module 17 | logger = logging.getLogger(__name__) 18 | 19 | # --- Default Values --- 20 | # (Defaults remain the same) 21 | DEFAULT_API_KEY: str = "" 22 | DEFAULT_API_URL: str = "" 23 | DEFAULT_MODEL_ID_STRING: str = "" 24 | DEFAULT_CURRENTLY_SELECTED_MODEL_ID: str = "" 25 | DEFAULT_AUTO_STARTUP_ENABLED: bool = False 26 | DEFAULT_APP_THEME: str = "system" # Default theme is system 27 | DEFAULT_INCLUDE_CLI_CONTEXT: bool = True 28 | DEFAULT_INCLUDE_TIMESTAMP: bool = False # Default to NOT include timestamp 29 | DEFAULT_ENABLE_MULTI_STEP: bool = False 30 | DEFAULT_MULTI_STEP_MAX_ITERATIONS: int = 5 # Default max iterations 31 | DEFAULT_AUTO_INCLUDE_UI_INFO: bool = False # Default to NOT automatically include UI info 32 | 33 | # --- Global Config State (Managed within this module) --- 34 | # (Global state variables remain the same) 35 | API_KEY: str = DEFAULT_API_KEY 36 | API_URL: str = DEFAULT_API_URL 37 | MODEL_ID_STRING: str = DEFAULT_MODEL_ID_STRING 38 | CURRENTLY_SELECTED_MODEL_ID: str = DEFAULT_CURRENTLY_SELECTED_MODEL_ID 39 | AUTO_STARTUP_ENABLED: bool = DEFAULT_AUTO_STARTUP_ENABLED 40 | APP_THEME: str = DEFAULT_APP_THEME 41 | INCLUDE_CLI_CONTEXT: bool = DEFAULT_INCLUDE_CLI_CONTEXT 42 | INCLUDE_TIMESTAMP_IN_PROMPT: bool = DEFAULT_INCLUDE_TIMESTAMP 43 | ENABLE_MULTI_STEP: bool = DEFAULT_ENABLE_MULTI_STEP 44 | MULTI_STEP_MAX_ITERATIONS: int = DEFAULT_MULTI_STEP_MAX_ITERATIONS 45 | AUTO_INCLUDE_UI_INFO: bool = DEFAULT_AUTO_INCLUDE_UI_INFO 46 | 47 | # --- Configuration Handling (Using QSettings primarily) --- 48 | def get_settings() -> QSettings: 49 | """Get a QSettings object, ensuring Org/App names are set.""" 50 | # Log the attempt to get settings 51 | logger.debug("Attempting to get QSettings instance.") 52 | try: 53 | # Ensure Org/App names are set before creating QSettings 54 | if not QCoreApplication.organizationName(): 55 | logger.debug("Organization name not set, setting to default: %s", ORG_NAME) 56 | QCoreApplication.setOrganizationName(ORG_NAME) 57 | if not QCoreApplication.applicationName(): 58 | logger.debug("Application name not set, setting to default: %s", SETTINGS_APP_NAME) 59 | QCoreApplication.setApplicationName(SETTINGS_APP_NAME) 60 | 61 | # Use INI format for better readability if opened manually 62 | settings = QSettings(QSettings.Format.IniFormat, QSettings.Scope.UserScope, ORG_NAME, SETTINGS_APP_NAME) 63 | settings_path = settings.fileName() 64 | logger.info(f"Using settings file: {settings_path}") # Log path even if it doesn't exist yet 65 | return settings 66 | except Exception as e: 67 | logger.error("Failed to create QSettings instance.", exc_info=True) 68 | # Propagate the exception or return a dummy object, depending on desired robustness 69 | raise # Re-raise the exception for now 70 | 71 | def load_config() -> tuple[bool, str]: 72 | """ 73 | Loads configuration from QSettings (INI format). 74 | Updates the global variables in this module. Logs the process. 75 | Returns: 76 | tuple: (bool: success, str: message) 77 | """ 78 | global API_KEY, API_URL, MODEL_ID_STRING, CURRENTLY_SELECTED_MODEL_ID, AUTO_STARTUP_ENABLED, APP_THEME 79 | global INCLUDE_CLI_CONTEXT, INCLUDE_TIMESTAMP_IN_PROMPT, ENABLE_MULTI_STEP, MULTI_STEP_MAX_ITERATIONS 80 | global AUTO_INCLUDE_UI_INFO 81 | 82 | logger.info("Loading configuration from QSettings...") 83 | try: 84 | settings = get_settings() # Will log the settings file path 85 | settings_path = settings.fileName() # Get path again for logging existence check 86 | if os.path.exists(settings_path): 87 | logger.info(f"Settings file exists: {settings_path}") 88 | else: 89 | logger.info(f"Settings file does not exist yet (will use defaults): {settings_path}") 90 | 91 | # --- Load API settings --- 92 | logger.debug("Loading [api] settings group...") 93 | settings.beginGroup("api") 94 | API_KEY = settings.value("key", DEFAULT_API_KEY, type=str) 95 | # Log API Key presence, not the key itself 96 | logger.debug("Loaded API Key: %s", "Present" if API_KEY else "Absent") 97 | API_URL = settings.value("url", DEFAULT_API_URL, type=str) 98 | logger.debug("Loaded API URL: %s", API_URL if API_URL else "") 99 | MODEL_ID_STRING = settings.value("model_id_string", DEFAULT_MODEL_ID_STRING, type=str) 100 | logger.debug("Loaded Model ID String: %s", MODEL_ID_STRING if MODEL_ID_STRING else "") 101 | settings.endGroup() 102 | 103 | # --- Load General settings --- 104 | logger.debug("Loading [general] settings group...") 105 | settings.beginGroup("general") 106 | AUTO_STARTUP_ENABLED = settings.value("auto_startup", DEFAULT_AUTO_STARTUP_ENABLED, type=bool) 107 | logger.debug("Loaded Auto Startup Enabled: %s", AUTO_STARTUP_ENABLED) 108 | loaded_theme = settings.value("theme", DEFAULT_APP_THEME, type=str) 109 | logger.debug("Loaded Theme (raw): %s", loaded_theme) 110 | INCLUDE_CLI_CONTEXT = settings.value("include_cli_context", DEFAULT_INCLUDE_CLI_CONTEXT, type=bool) 111 | logger.debug("Loaded Include CLI Context: %s", INCLUDE_CLI_CONTEXT) 112 | CURRENTLY_SELECTED_MODEL_ID = settings.value("selected_model", DEFAULT_CURRENTLY_SELECTED_MODEL_ID, type=str) 113 | logger.debug("Loaded Selected Model ID (raw): %s", CURRENTLY_SELECTED_MODEL_ID if CURRENTLY_SELECTED_MODEL_ID else "") 114 | INCLUDE_TIMESTAMP_IN_PROMPT = settings.value("include_timestamp", DEFAULT_INCLUDE_TIMESTAMP, type=bool) 115 | logger.debug("Loaded Include Timestamp: %s", INCLUDE_TIMESTAMP_IN_PROMPT) 116 | ENABLE_MULTI_STEP = settings.value("enable_multi_step", DEFAULT_ENABLE_MULTI_STEP, type=bool) 117 | logger.debug("Loaded Enable Multi-Step: %s", ENABLE_MULTI_STEP) 118 | 119 | # Load max iterations with error handling 120 | loaded_iterations_raw = settings.value("multi_step_max_iterations", DEFAULT_MULTI_STEP_MAX_ITERATIONS) 121 | try: 122 | MULTI_STEP_MAX_ITERATIONS = int(loaded_iterations_raw) 123 | if MULTI_STEP_MAX_ITERATIONS < 1: 124 | logger.warning(f"Invalid multi_step_max_iterations value ({MULTI_STEP_MAX_ITERATIONS}) loaded. Resetting to default ({DEFAULT_MULTI_STEP_MAX_ITERATIONS}).") 125 | MULTI_STEP_MAX_ITERATIONS = DEFAULT_MULTI_STEP_MAX_ITERATIONS 126 | logger.debug("Loaded Multi-Step Max Iterations: %d", MULTI_STEP_MAX_ITERATIONS) 127 | except (ValueError, TypeError): 128 | logger.warning(f"Could not parse multi_step_max_iterations value ('{loaded_iterations_raw}'). Resetting to default ({DEFAULT_MULTI_STEP_MAX_ITERATIONS}).") 129 | MULTI_STEP_MAX_ITERATIONS = DEFAULT_MULTI_STEP_MAX_ITERATIONS 130 | 131 | # Load Auto Include UI Info setting 132 | AUTO_INCLUDE_UI_INFO = settings.value("auto_include_ui_info", DEFAULT_AUTO_INCLUDE_UI_INFO, type=bool) 133 | logger.debug("Loaded Auto Include UI Info: %s", AUTO_INCLUDE_UI_INFO) 134 | settings.endGroup() 135 | 136 | # --- Validate and set theme --- 137 | valid_themes = ["light", "dark", "system"] 138 | if loaded_theme not in valid_themes: 139 | logger.warning(f"Invalid theme '{loaded_theme}' found in settings. Defaulting to '{DEFAULT_APP_THEME}'.") 140 | APP_THEME = DEFAULT_APP_THEME 141 | else: 142 | APP_THEME = loaded_theme 143 | logger.debug("Validated Theme: %s", APP_THEME) 144 | 145 | # --- Validate selected model ID against the list --- 146 | available_models = [m.strip() for m in MODEL_ID_STRING.split(',') if m.strip()] 147 | logger.debug("Available models based on Model ID String: %s", available_models) 148 | if CURRENTLY_SELECTED_MODEL_ID and CURRENTLY_SELECTED_MODEL_ID not in available_models: 149 | logger.warning(f"Saved selected model '{CURRENTLY_SELECTED_MODEL_ID}' is not in the available list. Resetting selection.") 150 | CURRENTLY_SELECTED_MODEL_ID = available_models[0] if available_models else "" 151 | elif not CURRENTLY_SELECTED_MODEL_ID and available_models: 152 | logger.info(f"No model previously selected, defaulting to first available: '{available_models[0]}'") 153 | CURRENTLY_SELECTED_MODEL_ID = available_models[0] 154 | elif not available_models: 155 | # If no models are available, ensure selected ID is also empty 156 | CURRENTLY_SELECTED_MODEL_ID = "" 157 | logger.debug("Validated Selected Model ID: %s", CURRENTLY_SELECTED_MODEL_ID if CURRENTLY_SELECTED_MODEL_ID else "") 158 | 159 | # Log final loaded state 160 | logger.info(f"Configuration loaded - Theme: {APP_THEME}, AutoStart: {AUTO_STARTUP_ENABLED}, " 161 | f"IncludeCLIContext: {INCLUDE_CLI_CONTEXT}, IncludeTimestamp: {INCLUDE_TIMESTAMP_IN_PROMPT}, " 162 | f"EnableMultiStep: {ENABLE_MULTI_STEP}, MaxIterations: {MULTI_STEP_MAX_ITERATIONS}, " 163 | f"AutoIncludeUI: {AUTO_INCLUDE_UI_INFO}, SelectedModel: {CURRENTLY_SELECTED_MODEL_ID}") 164 | 165 | # --- Check if API configuration is incomplete --- 166 | config_complete = True 167 | message = "Configuration loaded successfully." 168 | if not API_KEY or not API_URL: 169 | logger.warning("API Key or API URL configuration is incomplete in QSettings.") 170 | config_complete = False 171 | message = "API Key/URL configuration incomplete. Please configure in Settings." 172 | if not MODEL_ID_STRING: 173 | logger.info("Model ID list is empty. AI features require configuration in Settings.") 174 | if config_complete: 175 | message = "Configuration loaded, but Model ID list is empty." 176 | 177 | logger.info(f"Final config load check: Success={config_complete}, Message='{message}'") 178 | return config_complete, message 179 | 180 | except Exception as e: 181 | logger.error("CRITICAL error during configuration loading.", exc_info=True) 182 | return False, f"Failed to load configuration due to an error: {e}" 183 | 184 | 185 | def save_config(api_key: str, api_url: str, model_id_string: str, auto_startup: bool, theme: str, 186 | include_cli_context: bool, include_timestamp: bool, enable_multi_step: bool, 187 | multi_step_max_iterations: int, auto_include_ui_info: bool, 188 | selected_model_id: str): 189 | """Saves configuration to QSettings (INI format) and updates globals. Logs the process.""" 190 | global API_KEY, API_URL, MODEL_ID_STRING, CURRENTLY_SELECTED_MODEL_ID, AUTO_STARTUP_ENABLED, APP_THEME 191 | global INCLUDE_CLI_CONTEXT, INCLUDE_TIMESTAMP_IN_PROMPT, ENABLE_MULTI_STEP, MULTI_STEP_MAX_ITERATIONS 192 | global AUTO_INCLUDE_UI_INFO 193 | 194 | logger.info("Saving configuration to QSettings...") 195 | try: 196 | settings = get_settings() 197 | 198 | # --- Log values being saved (DEBUG level, mask API key) --- 199 | logger.debug("Saving values:") 200 | logger.debug(" API Key: %s", "****" if api_key else "") # Mask API Key 201 | logger.debug(" API URL: %s", api_url if api_url else "") 202 | logger.debug(" Model ID String: %s", model_id_string if model_id_string else "") 203 | logger.debug(" Selected Model ID: %s", selected_model_id if selected_model_id else "") 204 | logger.debug(" Auto Startup: %s", auto_startup) 205 | logger.debug(" Theme: %s", theme) 206 | logger.debug(" Include CLI Context: %s", include_cli_context) 207 | logger.debug(" Include Timestamp: %s", include_timestamp) 208 | logger.debug(" Enable Multi-Step: %s", enable_multi_step) 209 | logger.debug(" Multi-Step Max Iterations: %d", multi_step_max_iterations) 210 | logger.debug(" Auto Include UI Info: %s", auto_include_ui_info) 211 | 212 | # --- Save API Settings --- 213 | settings.beginGroup("api") 214 | settings.setValue("key", api_key) 215 | settings.setValue("url", api_url) 216 | settings.setValue("model_id_string", model_id_string) 217 | settings.endGroup() 218 | 219 | # --- Save General Settings --- 220 | settings.beginGroup("general") 221 | settings.setValue("auto_startup", auto_startup) 222 | valid_themes = ["dark", "light", "system"] 223 | valid_theme = theme if theme in valid_themes else DEFAULT_APP_THEME 224 | if theme != valid_theme: 225 | logger.warning(f"Attempted to save invalid theme '{theme}', saving default '{valid_theme}' instead.") 226 | settings.setValue("theme", valid_theme) 227 | settings.setValue("include_cli_context", include_cli_context) 228 | settings.setValue("selected_model", selected_model_id) 229 | settings.setValue("include_timestamp", include_timestamp) 230 | settings.setValue("enable_multi_step", enable_multi_step) 231 | # Ensure saved iteration value is at least 1 232 | save_iterations = max(1, multi_step_max_iterations) 233 | if save_iterations != multi_step_max_iterations: 234 | logger.warning(f"Adjusted multi_step_max_iterations from {multi_step_max_iterations} to {save_iterations} before saving.") 235 | settings.setValue("multi_step_max_iterations", save_iterations) 236 | settings.setValue("auto_include_ui_info", auto_include_ui_info) 237 | settings.endGroup() 238 | 239 | # --- Sync settings to file --- 240 | logger.debug("Syncing settings to file...") 241 | settings.sync() 242 | 243 | # --- Check for save errors --- 244 | save_status = settings.status() 245 | if save_status != QSettings.Status.NoError: 246 | # Log error but continue updating globals and applying auto-startup 247 | logger.error(f"Error encountered while syncing settings to file: Status Code {save_status}") 248 | else: 249 | logger.info(f"Settings saved successfully to: {settings.fileName()}") 250 | 251 | # --- Update global variables immediately after attempting save --- 252 | API_KEY, API_URL = api_key, api_url 253 | MODEL_ID_STRING = model_id_string 254 | CURRENTLY_SELECTED_MODEL_ID = selected_model_id 255 | AUTO_STARTUP_ENABLED = auto_startup 256 | APP_THEME = valid_theme 257 | INCLUDE_CLI_CONTEXT = include_cli_context 258 | INCLUDE_TIMESTAMP_IN_PROMPT = include_timestamp 259 | ENABLE_MULTI_STEP = enable_multi_step 260 | MULTI_STEP_MAX_ITERATIONS = save_iterations # Use the validated value 261 | AUTO_INCLUDE_UI_INFO = auto_include_ui_info 262 | logger.info("Global config variables updated with saved values.") 263 | logger.debug(f"Updated globals - AutoStart={AUTO_STARTUP_ENABLED}, Theme={APP_THEME}, SelectedModel={CURRENTLY_SELECTED_MODEL_ID}") 264 | 265 | # --- Apply auto-startup change using the saved value --- 266 | logger.info(f"Applying auto-startup setting ({AUTO_STARTUP_ENABLED})...") 267 | try: 268 | set_auto_startup(AUTO_STARTUP_ENABLED) # set_auto_startup should contain its own logging 269 | except Exception as e: 270 | # Log the error from applying auto-startup 271 | logger.error("Error applying auto-startup setting.", exc_info=True) 272 | 273 | except Exception as e: 274 | logger.error("Unhandled error during configuration saving process.", exc_info=True) 275 | 276 | def reset_to_defaults_and_clear_cache(): 277 | """ 278 | Resets all settings in QSettings to their defaults and clears cached state. 279 | Also updates the global variables in this module. Logs the process. 280 | """ 281 | global API_KEY, API_URL, MODEL_ID_STRING, CURRENTLY_SELECTED_MODEL_ID, AUTO_STARTUP_ENABLED, APP_THEME 282 | global INCLUDE_CLI_CONTEXT, INCLUDE_TIMESTAMP_IN_PROMPT, ENABLE_MULTI_STEP, MULTI_STEP_MAX_ITERATIONS 283 | global AUTO_INCLUDE_UI_INFO 284 | 285 | logger.warning("--- Resetting all settings to defaults and clearing cache ---") 286 | try: 287 | settings = get_settings() 288 | 289 | # Clear ALL settings managed by QSettings for this application 290 | logger.info(f"Clearing all settings in {settings.fileName()}...") 291 | settings.clear() 292 | logger.debug("Syncing cleared settings...") 293 | settings.sync() 294 | 295 | if settings.status() != QSettings.Status.NoError: 296 | logger.error(f"Error encountered while clearing/syncing settings: Status {settings.status()}") 297 | else: 298 | logger.info("All settings cleared successfully.") 299 | 300 | # --- Reset global variables to defaults --- 301 | logger.info("Resetting global config variables to defaults...") 302 | API_KEY = DEFAULT_API_KEY 303 | API_URL = DEFAULT_API_URL 304 | MODEL_ID_STRING = DEFAULT_MODEL_ID_STRING 305 | CURRENTLY_SELECTED_MODEL_ID = DEFAULT_CURRENTLY_SELECTED_MODEL_ID 306 | AUTO_STARTUP_ENABLED = DEFAULT_AUTO_STARTUP_ENABLED 307 | APP_THEME = DEFAULT_APP_THEME 308 | INCLUDE_CLI_CONTEXT = DEFAULT_INCLUDE_CLI_CONTEXT 309 | INCLUDE_TIMESTAMP_IN_PROMPT = DEFAULT_INCLUDE_TIMESTAMP 310 | ENABLE_MULTI_STEP = DEFAULT_ENABLE_MULTI_STEP 311 | MULTI_STEP_MAX_ITERATIONS = DEFAULT_MULTI_STEP_MAX_ITERATIONS 312 | AUTO_INCLUDE_UI_INFO = DEFAULT_AUTO_INCLUDE_UI_INFO 313 | logger.info("Global config variables reset.") 314 | logger.debug(f"Defaults applied - AutoStart={AUTO_STARTUP_ENABLED}, Theme={APP_THEME}, SelectedModel={CURRENTLY_SELECTED_MODEL_ID}") 315 | 316 | # --- Explicitly disable auto-startup --- 317 | # Important because simply clearing settings might not remove the OS-level entry 318 | logger.info("Disabling platform-specific auto-startup explicitly after reset...") 319 | try: 320 | set_auto_startup(False) 321 | except Exception as e: 322 | logger.error("Error explicitly disabling auto-startup during reset.", exc_info=True) 323 | 324 | logger.warning("--- Settings reset complete ---") 325 | 326 | except Exception as e: 327 | logger.error("Unhandled error during settings reset process.", exc_info=True) 328 | 329 | 330 | def get_current_config() -> dict: 331 | """Returns the current configuration values held in this module's global state.""" 332 | # Log at DEBUG level as this might be called frequently 333 | logger.debug("get_current_config() called.") 334 | # <<< MODIFICATION START: Add auto UI info to returned dict >>> 335 | return { 336 | "api_key": API_KEY, 337 | "api_url": API_URL, 338 | "model_id_string": MODEL_ID_STRING, 339 | "currently_selected_model_id": CURRENTLY_SELECTED_MODEL_ID, 340 | "auto_startup": AUTO_STARTUP_ENABLED, 341 | "theme": APP_THEME, 342 | "include_cli_context": INCLUDE_CLI_CONTEXT, 343 | "include_timestamp_in_prompt": INCLUDE_TIMESTAMP_IN_PROMPT, 344 | "enable_multi_step": ENABLE_MULTI_STEP, 345 | "multi_step_max_iterations": MULTI_STEP_MAX_ITERATIONS, 346 | "auto_include_ui_info": AUTO_INCLUDE_UI_INFO, # Added field 347 | } 348 | # <<< MODIFICATION END >>> -------------------------------------------------------------------------------- /PowerAgent/core/logging_config.py: -------------------------------------------------------------------------------- 1 | # core/logging_config.py 2 | # -*- coding: utf-8 -*- 3 | 4 | import logging 5 | import logging.handlers 6 | import os 7 | import sys 8 | import platform # Import platform for potential platform-specific logic later 9 | 10 | def setup_logging(log_level=logging.INFO, console_log_level=logging.INFO, file_log_level=logging.DEBUG): 11 | """ 12 | Configures logging for the application. Creates the log directory if needed. 13 | 14 | Args: 15 | log_level: The base level for the root logger (e.g., logging.INFO). 16 | console_log_level: The level for console output. 17 | file_log_level: The level for file output. 18 | """ 19 | log_dir = None 20 | log_file_path = None 21 | 22 | # --- Determine Log Directory --- 23 | try: 24 | # Assume this script (logging_config.py) is in PowerAgent/core/ 25 | project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 26 | log_dir = os.path.join(project_root, 'logs') # Standard logs directory name 27 | if not os.path.exists(log_dir): 28 | print(f"Log directory '{log_dir}' does not exist. Attempting to create...") 29 | os.makedirs(log_dir) 30 | print(f"Log directory '{log_dir}' created successfully.") 31 | # Define log file path 32 | log_file_path = os.path.join(log_dir, 'power_agent.log') 33 | 34 | except OSError as e: 35 | print(f"CRITICAL: Error creating log directory '{log_dir}': {e}", file=sys.stderr) 36 | # Fallback: Try logging to the project root directory if 'logs' fails 37 | try: 38 | project_root = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 39 | log_file_path = os.path.join(project_root, 'power_agent_fallback.log') 40 | print(f"CRITICAL: Falling back to log file path: {log_file_path}", file=sys.stderr) 41 | except Exception as fallback_e: 42 | print(f"CRITICAL: Failed to set fallback log path in project root: {fallback_e}", file=sys.stderr) 43 | log_file_path = None # Disable file logging if fallback fails 44 | 45 | except Exception as e: 46 | print(f"CRITICAL: Unexpected error determining log path: {e}", file=sys.stderr) 47 | log_file_path = None # Disable file logging on unexpected errors 48 | 49 | # --- Logging Configuration Parameters --- 50 | log_format = '%(asctime)s - %(levelname)-8s - %(name)-25s - %(threadName)-10s - %(message)s' 51 | log_date_format = '%Y-%m-%d %H:%M:%S' 52 | log_file_max_bytes = 10 * 1024 * 1024 # 10 MB 53 | log_file_backup_count = 5 54 | 55 | # --- Get Root Logger --- 56 | # We configure the root logger directly for more control over handlers. 57 | root_logger = logging.getLogger() 58 | # Set the lowest level the root logger will handle. Handlers can have higher levels. 59 | root_logger.setLevel(min(log_level, console_log_level, file_log_level)) # Handle the lowest level specified 60 | 61 | # --- Clear Existing Handlers (Avoid Duplicates in interactive sessions/reloads) --- 62 | if root_logger.hasHandlers(): 63 | print("Clearing existing logging handlers to prevent duplication.") 64 | # Iterate over a copy since we're modifying the list 65 | for handler in root_logger.handlers[:]: 66 | try: 67 | # Flush and close handler before removing 68 | handler.flush() 69 | handler.close() 70 | except Exception as e: 71 | print(f"Warning: Error closing handler {handler}: {e}", file=sys.stderr) 72 | root_logger.removeHandler(handler) 73 | 74 | # --- Configure Formatter --- 75 | formatter = logging.Formatter(log_format, datefmt=log_date_format) 76 | 77 | # --- Configure RotatingFileHandler (If path is valid) --- 78 | if log_file_path: 79 | try: 80 | # Use RotatingFileHandler to limit log file size 81 | file_handler = logging.handlers.RotatingFileHandler( 82 | log_file_path, 83 | maxBytes=log_file_max_bytes, 84 | backupCount=log_file_backup_count, 85 | encoding='utf-8' # Explicitly use utf-8 86 | ) 87 | file_handler.setLevel(file_log_level) 88 | file_handler.setFormatter(formatter) 89 | root_logger.addHandler(file_handler) 90 | # Use print for initial setup messages as logging might not be fully ready 91 | print(f"File logging configured: Path='{log_file_path}', Level={logging.getLevelName(file_log_level)}") 92 | except PermissionError as pe: 93 | print(f"ERROR: Permission denied for log file '{log_file_path}'. Check permissions. {pe}", file=sys.stderr) 94 | except Exception as e: 95 | print(f"ERROR: Could not configure file logging to '{log_file_path}': {e}", file=sys.stderr) 96 | else: 97 | print("ERROR: Log file path could not be determined or created. File logging disabled.", file=sys.stderr) 98 | 99 | # --- Configure StreamHandler (Console Output) --- 100 | try: 101 | console_handler = logging.StreamHandler(sys.stderr) # Log to stderr 102 | console_handler.setLevel(console_log_level) 103 | console_handler.setFormatter(formatter) 104 | root_logger.addHandler(console_handler) 105 | print(f"Console logging configured: Level={logging.getLevelName(console_log_level)}") 106 | except Exception as e: 107 | print(f"ERROR: Could not configure console logging: {e}", file=sys.stderr) 108 | 109 | # Check if any handlers were successfully added 110 | if not root_logger.hasHandlers(): 111 | print("CRITICAL: No logging handlers could be configured. Logging will not work.", file=sys.stderr) 112 | 113 | print("Logging setup sequence finished.") 114 | 115 | 116 | def handle_exception(exc_type, exc_value, exc_traceback): 117 | """ 118 | Global exception handler to log unhandled exceptions. 119 | To be used with sys.excepthook. 120 | """ 121 | # Do not log KeyboardInterrupt (Ctrl+C) 122 | if issubclass(exc_type, KeyboardInterrupt): 123 | # Call the default excepthook to ensure clean exit behavior 124 | sys.__excepthook__(exc_type, exc_value, exc_traceback) 125 | return 126 | 127 | logger = logging.getLogger("UnhandledException") # Use a specific logger name 128 | 129 | # Check if logging is actually configured before attempting to log 130 | # Check root logger level and if any handlers exist 131 | if logger.isEnabledFor(logging.CRITICAL) and logging.getLogger().hasHandlers(): 132 | logger.critical("Unhandled exception caught by sys.excepthook:", 133 | exc_info=(exc_type, exc_value, exc_traceback)) 134 | else: 135 | # Fallback to printing directly to stderr if logging isn't ready 136 | print("CRITICAL UNHANDLED EXCEPTION (Logging not fully configured):", file=sys.stderr) 137 | # Manually print the traceback to stderr 138 | import traceback as tb 139 | tb.print_exception(exc_type, exc_value, exc_traceback, file=sys.stderr) -------------------------------------------------------------------------------- /PowerAgent/core/stream_handler.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/core/stream_handler.py 3 | # (CORRECTED - Improved termination logic & error handling) 4 | # ---------------------------------------- 5 | # core/stream_handler.py 6 | # -*- coding: utf-8 -*- 7 | 8 | """ 9 | Handles reading from subprocess streams asynchronously. 10 | """ 11 | 12 | import os 13 | import platform 14 | import traceback 15 | import io 16 | import time # Import time for sleep 17 | from PySide6.QtCore import QObject, Signal, QThread 18 | 19 | class StreamWorker(QObject): 20 | finished = Signal() 21 | output_ready = Signal(bytes) # Emits raw bytes 22 | 23 | def __init__(self, stream, stop_flag_func, line_list=None, filter_clixml=False): 24 | """ 25 | Worker to read from a stream non-blockingly using os.read. 26 | 27 | Args: 28 | stream: The stream object (e.g., process.stdout, process.stderr). 29 | stop_flag_func: A callable function that returns True if the worker should stop (external signal). 30 | line_list (optional): A list to append raw chunks to (used for stderr checking later). 31 | filter_clixml (optional): If True on Windows, attempts to filter out CLIXML error messages. 32 | """ 33 | super().__init__() 34 | self.stream = stream 35 | self.external_stop_flag_func = stop_flag_func # Rename for clarity 36 | self._should_stop = False # Internal flag for explicit stop 37 | self.line_list = line_list 38 | self.filter_clixml = filter_clixml and platform.system() == "Windows" 39 | self.stream_fd = -1 40 | self.stream_name = "Unknown" # For logging 41 | 42 | if hasattr(stream, 'fileno'): 43 | try: 44 | self.stream_fd = stream.fileno() 45 | # Determine stream name for logging 46 | if stream is getattr(stream, '__stdout__', None): self.stream_name = "stdout" 47 | elif stream is getattr(stream, '__stderr__', None): self.stream_name = "stderr" 48 | else: self.stream_name = f"FD {self.stream_fd}" 49 | except (OSError, ValueError, io.UnsupportedOperation) as e: 50 | print(f"[StreamWorker {self.stream_name}] Warning: Could not get fileno: {e}. os.read unavailable.") 51 | else: 52 | self.stream_name = type(stream).__name__ 53 | print(f"[StreamWorker {self.stream_name}] Warning: Stream object has no fileno attribute.") 54 | 55 | 56 | def stop(self): 57 | """Signals the worker to stop its loop.""" 58 | self._should_stop = True 59 | print(f"[StreamWorker {self.stream_name}] Stop signal received internally.") 60 | 61 | def run(self): 62 | """Reads from the stream and emits data until EOF or stop signal.""" 63 | print(f"[StreamWorker {self.stream_name}] Reader thread started.") 64 | try: 65 | # Loop while neither external nor internal stop flag is set 66 | while not self.external_stop_flag_func() and not self._should_stop: 67 | try: 68 | chunk = None 69 | read_attempted = False 70 | 71 | if self.stream_fd != -1: 72 | try: 73 | # Use os.read for potentially better non-blocking behavior on pipes 74 | chunk = os.read(self.stream_fd, 4096) 75 | read_attempted = True 76 | except BlockingIOError: 77 | # This is expected if no data is available, sleep briefly 78 | QThread.msleep(20) # Small sleep to yield CPU 79 | continue 80 | except (OSError, ValueError) as e: 81 | # Errors like EBADF (bad file descriptor) likely mean the pipe closed 82 | print(f"[StreamWorker {self.stream_name}] Stream read error (os.read): {e}. Stopping read.") 83 | self._should_stop = True # Ensure loop exit 84 | break # Exit loop 85 | else: 86 | # Fallback using stream.read() - potentially blocking 87 | try: 88 | # Check if stream is closed before attempting read 89 | if hasattr(self.stream, 'closed') and self.stream.closed: 90 | print(f"[StreamWorker {self.stream_name}] Fallback stream detected as closed.") 91 | self._should_stop = True 92 | break 93 | # This might block if stream doesn't support non-blocking reads 94 | chunk = self.stream.read(4096) 95 | read_attempted = True 96 | except io.UnsupportedOperation: 97 | print(f"[StreamWorker {self.stream_name}] Fallback stream read failed: Unsupported operation.") 98 | self._should_stop = True 99 | break 100 | except Exception as read_err: 101 | print(f"[StreamWorker {self.stream_name}] Fallback stream read error: {read_err}") 102 | self._should_stop = True 103 | break 104 | 105 | # If read was attempted and returned no data, it usually means EOF 106 | if read_attempted and not chunk: 107 | print(f"[StreamWorker {self.stream_name}] EOF detected.") 108 | self._should_stop = True 109 | break # Exit loop 110 | 111 | # If a chunk was successfully read 112 | if chunk: 113 | # Check flags again *after* potential blocking read 114 | if self.external_stop_flag_func() or self._should_stop: 115 | print(f"[StreamWorker {self.stream_name}] Stop flag set after read, discarding chunk.") 116 | break 117 | 118 | emit_chunk = True 119 | if self.line_list is not None: 120 | self.line_list.append(chunk) # Store raw chunk 121 | 122 | if self.filter_clixml: 123 | try: 124 | if chunk.strip().startswith(b"#< CLIXML"): 125 | emit_chunk = False 126 | # print(f"[StreamWorker {self.stream_name}] Filtered potential CLIXML block.") # Debug log 127 | except Exception: pass # Ignore errors during filtering check 128 | 129 | if emit_chunk: 130 | try: 131 | self.output_ready.emit(chunk) 132 | except RuntimeError: # Target object likely deleted 133 | print(f"[StreamWorker {self.stream_name}] Target for signal emission deleted. Stopping.") 134 | self._should_stop = True 135 | break 136 | else: 137 | # If no chunk was read (e.g., non-blocking read returned nothing), sleep briefly 138 | QThread.msleep(20) 139 | 140 | except Exception as e: 141 | print(f"[StreamWorker {self.stream_name}] Unexpected error in read loop: {e}") 142 | traceback.print_exc() 143 | self._should_stop = True # Exit loop on unexpected error 144 | break 145 | finally: 146 | print(f"[StreamWorker {self.stream_name}] Read loop finished (Should Stop: {self._should_stop}, External Stop: {self.external_stop_flag_func()}).") 147 | # Do NOT close the stream here - Popen manages the pipe lifecycle. 148 | # Let the command_executor handle closing if necessary (though usually not needed). 149 | try: 150 | self.finished.emit() 151 | print(f"[StreamWorker {self.stream_name}] Finished signal emitted.") 152 | except RuntimeError: 153 | print(f"[StreamWorker {self.stream_name}] Warning: Could not emit finished signal (target likely deleted).") 154 | except Exception as sig_err: 155 | print(f"[StreamWorker {self.stream_name}] Error emitting finished signal: {sig_err}") -------------------------------------------------------------------------------- /PowerAgent/core/worker_utils.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/core/worker_utils.py 3 | # (NEW FILE) 4 | # ---------------------------------------- 5 | # core/worker_utils.py 6 | # -*- coding: utf-8 -*- 7 | 8 | """ 9 | Utility functions shared by worker threads. 10 | """ 11 | 12 | import locale 13 | import platform 14 | 15 | def decode_output(output_bytes: bytes) -> str: 16 | """ 17 | Attempts to decode bytes, prioritizing UTF-8, then system preferred, 18 | then 'mbcs' (Windows), finally falling back to latin-1 with replacements. 19 | """ 20 | if not isinstance(output_bytes, bytes): 21 | print(f"Warning: decode_output received non-bytes type: {type(output_bytes)}. Returning as is.") 22 | if isinstance(output_bytes, str): return output_bytes 23 | try: return str(output_bytes) 24 | except: return repr(output_bytes) 25 | 26 | if not output_bytes: return "" 27 | 28 | # 1. Try UTF-8 (most common) 29 | try: 30 | decoded_str = output_bytes.decode('utf-8') 31 | # print("[Decode] Success with utf-8") # Optional debug print 32 | return decoded_str 33 | except UnicodeDecodeError: 34 | # print("[Decode] Failed utf-8, trying system preferred...") # Optional debug print 35 | pass # Continue to next attempt 36 | except Exception as e: 37 | print(f"Error decoding with utf-8: {e}, trying system preferred...") 38 | pass # Continue to next attempt 39 | 40 | # 2. Try system preferred encoding (e.g., locale settings) 41 | system_preferred = locale.getpreferredencoding(False) 42 | if system_preferred and system_preferred.lower() != 'utf-8': # Avoid trying UTF-8 again 43 | try: 44 | # Use replace to avoid crashing on the second attempt 45 | decoded_str = output_bytes.decode(system_preferred, errors='replace') 46 | print(f"[Decode] Success with system preferred: {system_preferred}") # Info print 47 | return decoded_str 48 | except UnicodeDecodeError: 49 | print(f"[Decode] Failed system preferred '{system_preferred}', trying mbcs (Windows) or fallback...") 50 | pass # Continue to next attempt 51 | except Exception as e: 52 | print(f"Error decoding with system preferred '{system_preferred}': {e}, trying mbcs (Windows) or fallback...") 53 | pass 54 | 55 | # 3. Try 'mbcs' (mainly for Windows ANSI compatibility) 56 | if platform.system() == 'Windows': 57 | try: 58 | # Use replace to avoid crashing here 59 | decoded_str = output_bytes.decode('mbcs', errors='replace') 60 | print("[Decode] Success with mbcs") # Info print 61 | return decoded_str 62 | except UnicodeDecodeError: 63 | print("[Decode] Failed mbcs, using final fallback latin-1...") 64 | pass # Continue to next attempt 65 | except Exception as e: 66 | print(f"Error decoding with mbcs: {e}, using final fallback latin-1...") 67 | pass 68 | 69 | # 4. Final fallback (Latin-1 rarely fails but might not be correct) 70 | print("[Decode] Using final fallback: latin-1") 71 | return output_bytes.decode('latin-1', errors='replace') -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/__init__.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/__init__.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/main_window.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/main_window.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/main_window_handlers.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/main_window_handlers.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/main_window_state.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/main_window_state.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/main_window_updates.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/main_window_updates.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/main_window_workers.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/main_window_workers.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/palette.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/palette.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/settings_dialog.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/settings_dialog.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/stylesheets.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/stylesheets.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/__pycache__/ui_components.cpython-313.pyc: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/yeahhe365/PowerAgent/ae72079a08c86f77d46f4c047063578cf188b646/PowerAgent/gui/__pycache__/ui_components.cpython-313.pyc -------------------------------------------------------------------------------- /PowerAgent/gui/main_window.py: -------------------------------------------------------------------------------- 1 | # gui/main_window.py 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import os 6 | import time 7 | import platform 8 | import traceback 9 | import logging # Import logging 10 | from collections import deque 11 | 12 | from PySide6.QtWidgets import ( 13 | QMainWindow, QWidget, QSplitter, QApplication, QComboBox, QFrame, 14 | QLineEdit, QTextEdit, QLabel, QPushButton, QMessageBox # Added QMessageBox 15 | ) 16 | from PySide6.QtCore import Qt, Slot, QSettings, QCoreApplication, QStandardPaths, QSize, QEvent, QThread 17 | from PySide6.QtGui import ( 18 | QTextCursor, QPalette, QFont, QIcon, QColor, 19 | QAction, QKeySequence 20 | ) 21 | 22 | # --- Project Imports --- 23 | from constants import APP_NAME, get_color 24 | from core import config 25 | from core.workers import ApiWorkerThread, ManualCommandThread 26 | from .palette import setup_palette 27 | from .ui_components import create_ui_elements, StatusIndicatorWidget 28 | 29 | # --- Mixin Imports --- 30 | from .main_window_handlers import HandlersMixin 31 | from .main_window_updates import UpdatesMixin 32 | from .main_window_state import StateMixin 33 | from .main_window_workers import WorkersMixin 34 | 35 | # --- Get Logger --- 36 | logger = logging.getLogger(__name__) 37 | 38 | # --- Main Window Class --- 39 | class MainWindow(QMainWindow, HandlersMixin, UpdatesMixin, StateMixin, WorkersMixin): 40 | 41 | def __init__(self, application_base_dir=None, parent=None): 42 | logger.info("--- MainWindow Initializing ---") 43 | # Initialize QMainWindow first 44 | super().__init__(parent) 45 | 46 | # --- 1. Determine Base Directory --- 47 | # (Logic remains the same) 48 | start_time = time.monotonic() # Time the init process 49 | try: 50 | if application_base_dir: 51 | self.application_base_dir = application_base_dir 52 | logger.debug("Using provided application base directory.") 53 | elif getattr(sys, 'frozen', False): 54 | self.application_base_dir = os.path.dirname(sys.executable) 55 | logger.debug("Running as frozen executable.") 56 | else: 57 | try: 58 | main_script_path = os.path.abspath(sys.argv[0]) 59 | self.application_base_dir = os.path.dirname(main_script_path) 60 | logger.debug("Running as script, determined from sys.argv[0].") 61 | except Exception: 62 | logger.warning("Could not determine base directory from sys.argv[0], falling back to __file__.", exc_info=False) 63 | # Fallback: determine based on the directory of main_window.py 64 | self.application_base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 65 | logger.debug("Fallback to parent of current file directory.") 66 | logger.info(f"Application Base Directory set to: {self.application_base_dir}") 67 | except Exception as e: 68 | logger.error("Failed to determine application base directory!", exc_info=True) 69 | # Attempt a reasonable fallback 70 | self.application_base_dir = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) 71 | logger.warning(f"Using fallback base directory: {self.application_base_dir}") 72 | 73 | 74 | # --- 1.5 Get Launch Directory EARLY --- 75 | try: 76 | # This gets the directory where the script/exe was *launched from* 77 | self.launch_directory = os.getcwd() 78 | logger.info(f"Detected Launch Directory (Initial CWD): {self.launch_directory}") 79 | except OSError as e: 80 | logger.critical(f"Failed to get current working directory: {e}. Falling back to app base dir.", exc_info=True) 81 | self.launch_directory = self.application_base_dir # Fallback 82 | 83 | # --- 2. Define and Ensure 'Space' Directory --- 84 | self.initial_directory = os.path.normpath(os.path.join(self.application_base_dir, "Space")) 85 | logger.info(f"Reference 'Space' directory path: {self.initial_directory}") 86 | try: 87 | os.makedirs(self.initial_directory, exist_ok=True) 88 | logger.info(f"Ensured reference 'Space' directory exists.") 89 | except OSError as e: 90 | logger.warning(f"Failed to create reference 'Space' directory '{self.initial_directory}': {e}.") 91 | 92 | # --- 3. Initialize State Variables --- 93 | logger.debug("Initializing state variables...") 94 | self.current_directory = self.launch_directory # Initial state set to launch dir 95 | logger.info(f"Initial internal CWD state set to Launch Directory: {self.current_directory}") 96 | 97 | self.conversation_history = deque(maxlen=50) # Chat history 98 | self.cli_command_history = deque(maxlen=100) # CLI input history 99 | self.cli_history_index = -1 100 | self.api_worker_thread: ApiWorkerThread | None = None 101 | self.manual_cmd_thread: ManualCommandThread | None = None 102 | self.settings_dialog_open = False 103 | self._closing = False 104 | logger.debug("State variables initialized.") 105 | 106 | # --- 4. Initialize UI Element Placeholders --- 107 | # (No logging needed here, just definitions) 108 | self.model_selector_combo: QComboBox | None = None 109 | self.status_indicator: StatusIndicatorWidget | None = None 110 | self.cli_prompt_label: QLabel | None = None 111 | self.cli_output_display: QTextEdit | None = None 112 | self.cli_input: QLineEdit | None = None 113 | self.chat_history_display: QTextEdit | None = None 114 | self.chat_input: QTextEdit | None = None 115 | self.send_button: QPushButton | None = None 116 | self.clear_chat_button: QPushButton | None = None 117 | self.clear_cli_button: QPushButton | None = None 118 | self.splitter: QSplitter | None = None 119 | self.status_bar = None 120 | 121 | # --- 5. Load State (History, etc.) --- 122 | # load_state() has its own logging 123 | logger.info("Loading initial state (Chat/CLI History, etc.)...") 124 | try: 125 | self.load_state() # Defined in StateMixin 126 | logger.info(f"State loaded. Internal CWD is now: {self.current_directory}") 127 | except Exception as e: 128 | logger.error("Error during initial state load.", exc_info=True) 129 | # load_state() has fallback logic, but log the error here too 130 | 131 | # --- 6. Sync Process CWD --- 132 | self._sync_process_cwd() # Method contains logging 133 | 134 | # --- 7. Basic Window Setup --- 135 | logger.debug("Setting up basic window properties (title, geometry, icon)...") 136 | self.setWindowTitle(APP_NAME) 137 | self.setGeometry(100, 100, 850, 585) # Default geometry 138 | self.set_window_icon() # Defined in UpdatesMixin (should add logging there) 139 | logger.debug("Basic window setup complete.") 140 | 141 | # --- 8. Setup UI Elements --- 142 | logger.info("Setting up UI elements...") 143 | try: 144 | self.setup_ui() # Calls create_ui_elements 145 | logger.info("UI elements created and assigned.") 146 | # Verify key widgets were created 147 | if not self.cli_input: logger.warning("cli_input widget was not created during setup_ui!") 148 | if not self.chat_input: logger.warning("chat_input widget was not created during setup_ui!") 149 | if not self.splitter: logger.warning("splitter widget was not created during setup_ui!") 150 | except Exception as ui_setup_err: 151 | logger.critical("CRITICAL error during UI setup!", exc_info=True) 152 | # Optionally show error to user and exit? 153 | QMessageBox.critical(self, "UI Setup Error", f"Failed to set up UI elements:\n{ui_setup_err}") 154 | sys.exit(1) 155 | 156 | # --- 9. Restore Splitter State --- 157 | logger.debug("Restoring splitter state...") 158 | try: 159 | settings = config.get_settings() 160 | splitter_state_value = settings.value("ui/splitter_state") # Fetch raw value 161 | if self.splitter and splitter_state_value: 162 | # Check type before restoring (should be bytes or bytearray) 163 | if isinstance(splitter_state_value, (bytes, bytearray)): 164 | if self.splitter.restoreState(splitter_state_value): 165 | logger.info("Restored splitter state from settings.") 166 | else: 167 | logger.warning("Failed to restore splitter state (restoreState returned False). Setting defaults.") 168 | self._set_default_splitter_sizes() 169 | else: 170 | logger.warning(f"Invalid splitter state type found in settings: {type(splitter_state_value)}. Setting defaults.") 171 | self._set_default_splitter_sizes() 172 | elif self.splitter: 173 | logger.info("No splitter state found in settings or splitter invalid. Setting default sizes.") 174 | self._set_default_splitter_sizes() 175 | else: 176 | logger.warning("Splitter object not found after UI setup. Cannot restore/set state.") 177 | except Exception as e: 178 | logger.error("Error restoring or setting splitter sizes.", exc_info=True) 179 | if self.splitter: self._set_default_splitter_sizes() # Try setting defaults on error 180 | 181 | # --- 10. Post-UI Setup --- 182 | # apply_theme_specific_styles() and load_and_apply_state() should have own logging 183 | logger.info("Applying theme-specific styles...") 184 | self.apply_theme_specific_styles() 185 | logger.info("Loading and applying display state (history, etc.)...") 186 | self.load_and_apply_state() 187 | 188 | # --- Event Filter Installation --- 189 | logger.debug("Installing event filters for focus switching...") 190 | filter_installed = False 191 | if self.cli_input: 192 | self.cli_input.installEventFilter(self) 193 | logger.debug("Installed event filter on cli_input.") 194 | filter_installed = True 195 | else: 196 | logger.warning("cli_input not initialized, cannot install event filter.") 197 | 198 | if self.chat_input: 199 | self.chat_input.installEventFilter(self) 200 | logger.debug("Installed event filter on chat_input.") 201 | filter_installed = True 202 | else: 203 | logger.warning("chat_input not initialized, cannot install event filter.") 204 | if not filter_installed: logger.warning("No event filters installed for focus switching.") 205 | 206 | # --- 11. Set Initial Status --- 207 | # update_status_indicator() and update_model_selector() should have logging 208 | logger.debug("Updating initial status indicator and model selector...") 209 | self.update_status_indicator(False) 210 | self.update_model_selector() 211 | 212 | # --- 12. Add Welcome Message --- 213 | if not self.conversation_history: 214 | welcome_message = f"欢迎使用 {APP_NAME}!当前工作目录已设置为您的启动目录: '{self.current_directory}'。\n输入 '/help' 查看命令。" 215 | logger.info("Adding initial welcome message.") 216 | # add_chat_message should have its own logging 217 | self.add_chat_message("System", welcome_message, add_to_internal_history=False) 218 | else: 219 | logger.info(f"Skipping initial welcome message as {len(self.conversation_history)} history items were loaded.") 220 | 221 | # --- 13. Update Prompt & Focus --- 222 | logger.debug("Updating initial CLI prompt...") 223 | self.update_prompt() # Should have logging 224 | 225 | # <<< MODIFICATION: Set initial focus to chat input >>> 226 | if self.chat_input: 227 | logger.info("Setting initial focus to chat input.") # Changed log level to INFO for visibility 228 | self.chat_input.setFocus() 229 | elif self.cli_input: # Fallback to CLI if chat input somehow failed 230 | logger.warning("Chat input not available. Falling back to setting focus on CLI input.") 231 | self.cli_input.setFocus() 232 | else: 233 | logger.warning("Cannot set initial focus, neither chat_input nor cli_input are available.") 234 | # <<< END MODIFICATION >>> 235 | 236 | init_duration = time.monotonic() - start_time 237 | logger.info(f"--- MainWindow Initialization Finished ({init_duration:.3f}s) ---") 238 | 239 | def _sync_process_cwd(self): 240 | """Attempts to set the OS process CWD to self.current_directory with fallbacks.""" 241 | logger.info(f"Attempting to sync OS process CWD to internal state: {self.current_directory}") 242 | target_dir_to_set = self.current_directory 243 | original_os_cwd = os.getcwd() # Get current OS CWD for comparison 244 | 245 | if original_os_cwd == target_dir_to_set: 246 | logger.info("OS process CWD already matches target directory. No change needed.") 247 | return 248 | 249 | try: 250 | if os.path.isdir(target_dir_to_set): 251 | os.chdir(target_dir_to_set) 252 | # Verify change 253 | if os.getcwd() == target_dir_to_set: 254 | logger.info(f"Successfully set OS process CWD to: {target_dir_to_set}") 255 | else: 256 | # This case is unlikely but possible with complex permissions/mounts 257 | logger.error(f"OS chdir to '{target_dir_to_set}' reported success, but getcwd() returned '{os.getcwd()}'. CWD sync failed.") 258 | # Revert internal state? Or keep internal state and log discrepancy? Let's log. 259 | # self.current_directory = os.getcwd() # Option: Revert internal state 260 | else: 261 | logger.warning(f"Target directory '{target_dir_to_set}' is not valid or inaccessible. Falling back...") 262 | fallback_used = False 263 | # Try falling back to initial ('Space') directory 264 | if os.path.isdir(self.initial_directory): 265 | logger.info(f"Attempting fallback to initial directory: {self.initial_directory}") 266 | os.chdir(self.initial_directory) 267 | if os.getcwd() == self.initial_directory: 268 | logger.info(f"OS Process CWD set to fallback (Space dir): {self.initial_directory}") 269 | self.current_directory = self.initial_directory # Update internal state 270 | self.save_state() # Save the new fallback state 271 | fallback_used = True 272 | else: 273 | logger.error(f"Fallback to '{self.initial_directory}' failed. getcwd() is '{os.getcwd()}'.") 274 | else: 275 | logger.warning(f"Fallback 'Space' directory '{self.initial_directory}' also invalid or inaccessible.") 276 | 277 | # If Space fallback failed, try app base directory 278 | if not fallback_used: 279 | if os.path.isdir(self.application_base_dir): 280 | logger.info(f"Attempting fallback to application base directory: {self.application_base_dir}") 281 | os.chdir(self.application_base_dir) 282 | if os.getcwd() == self.application_base_dir: 283 | logger.info(f"OS Process CWD set to fallback (app base dir): {self.application_base_dir}") 284 | self.current_directory = self.application_base_dir 285 | self.save_state() 286 | fallback_used = True 287 | else: 288 | logger.error(f"Fallback to '{self.application_base_dir}' failed. getcwd() is '{os.getcwd()}'.") 289 | else: 290 | logger.warning(f"Application base directory '{self.application_base_dir}' also invalid or inaccessible.") 291 | 292 | # If all fallbacks fail, log the final state 293 | if not fallback_used: 294 | final_cwd = os.getcwd() 295 | logger.critical(f"All CWD sync attempts failed. OS process CWD remains at '{final_cwd}'.") 296 | # Update internal state to match the final OS reality 297 | if self.current_directory != final_cwd: 298 | logger.warning(f"Updating internal CWD state from '{self.current_directory}' to match OS CWD '{final_cwd}'.") 299 | self.current_directory = final_cwd 300 | # Maybe save this unexpected state? 301 | # self.save_state() 302 | except OSError as e: 303 | logger.critical(f"OSError occurred during CWD sync to '{target_dir_to_set}'.", exc_info=True) 304 | final_cwd = os.getcwd() 305 | logger.warning(f"Using OS CWD '{final_cwd}' due to exception during sync.") 306 | if self.current_directory != final_cwd: 307 | self.current_directory = final_cwd 308 | # self.save_state() # Save the state resulting from the error? 309 | except Exception as e: 310 | logger.critical("Unexpected error during CWD sync.", exc_info=True) 311 | final_cwd = os.getcwd() 312 | logger.warning(f"Using OS CWD '{final_cwd}' due to unexpected exception.") 313 | if self.current_directory != final_cwd: 314 | self.current_directory = final_cwd 315 | # self.save_state() 316 | 317 | # Update prompt regardless of success/failure to reflect final internal state 318 | self.update_prompt() 319 | logger.info("CWD synchronization process finished.") 320 | 321 | 322 | def setup_ui(self): 323 | """Creates and arranges all UI widgets by calling the external setup function.""" 324 | logger.info("Calling create_ui_elements...") 325 | create_ui_elements(self) # External function, assumed to work or raise error 326 | logger.info("create_ui_elements finished.") 327 | 328 | 329 | def _set_default_splitter_sizes(self): 330 | """Helper to set default splitter sizes.""" 331 | if self.splitter: 332 | try: 333 | # Use a reasonable default split ratio, e.g., 55% CLI, 45% Chat 334 | default_width = self.geometry().width() # Use current width 335 | if default_width < 100: # Prevent division by zero or tiny sizes 336 | logger.warning(f"Window width ({default_width}) too small for default splitter sizes. Skipping.") 337 | return 338 | cli_width = int(default_width * 0.55) 339 | chat_width = default_width - cli_width 340 | logger.info(f"Setting default splitter sizes: CLI={cli_width}, Chat={chat_width}") 341 | self.splitter.setSizes([cli_width, chat_width]) 342 | except Exception as e: 343 | logger.error("Could not set default splitter sizes.", exc_info=True) 344 | else: 345 | logger.warning("Cannot set default splitter sizes: splitter widget not found.") 346 | 347 | def eventFilter(self, watched, event): 348 | """Handles Tab key presses on cli_input and chat_input for focus switching.""" 349 | if not self.cli_input or not self.chat_input: 350 | # Log this only once or rarely if it occurs often 351 | # logger.debug("Event filter called but input widgets not ready.") 352 | return super().eventFilter(watched, event) 353 | 354 | if event.type() == QEvent.Type.KeyPress: 355 | key = event.key() 356 | modifiers = event.modifiers() 357 | 358 | is_tab = key == Qt.Key.Key_Tab 359 | is_shift_tab = key == Qt.Key.Key_Backtab or (is_tab and (modifiers & Qt.KeyboardModifier.ShiftModifier)) 360 | is_plain_tab = is_tab and not (modifiers & Qt.KeyboardModifier.ShiftModifier) 361 | 362 | # --- Handle Plain Tab (Forward) --- 363 | if is_plain_tab: 364 | if watched == self.cli_input: 365 | logger.debug("Tab pressed on CLI input, focusing chat input.") 366 | self.chat_input.setFocus() 367 | return True # Event handled 368 | elif watched == self.chat_input: 369 | logger.debug("Tab pressed on Chat input, focusing CLI input.") 370 | self.cli_input.setFocus() 371 | return True # Event handled 372 | 373 | # --- Handle Shift+Tab (Backward) --- 374 | elif is_shift_tab: 375 | if watched == self.cli_input: 376 | logger.debug("Shift+Tab pressed on CLI input, focusing chat input.") 377 | self.chat_input.setFocus() 378 | return True # Event handled 379 | elif watched == self.chat_input: 380 | logger.debug("Shift+Tab pressed on Chat input, focusing CLI input.") 381 | self.cli_input.setFocus() 382 | return True # Event handled 383 | 384 | # Pass unhandled events to the base class 385 | return super().eventFilter(watched, event) 386 | 387 | 388 | def show_help(self): 389 | """Displays help information in the chat window.""" 390 | logger.info("Displaying help information in chat window.") 391 | # (Help text content remains the same) 392 | help_title = f"--- {APP_NAME} 帮助 ---" 393 | core_info = f""" 394 | **主要操作:** 395 | 1. **与 AI 对话 (上方聊天窗口):** 396 | - 从工具栏选择要使用的 AI 模型。 397 | - 输入你的任务请求 (例如: "列出当前目录的 python 文件", "创建 temp 目录", "模拟按下 CTRL+C 组合键")。 398 | - AI 会回复,并将建议的 `命令` 或 `键盘动作` 在下方 CLI 窗口回显后自动执行。 399 | - (可选) 如果在设置中启用“自动将近期 CLI 输出作为上下文发送给 AI”,则左侧 CLI 输出的**全部**内容会自动作为上下文发送。 400 | - 输入以 `/` 开头的命令执行特殊操作。 401 | 2. **执行手动命令 (下方命令行窗口):** 402 | - 应用程序启动时,默认工作目录是您**启动程序时所在的目录**。 403 | - 输入标准的 Shell 命令 (如 `dir`, `ls -l`, `cd ..`, `python script.py`)。 404 | - 按 Enter 执行。命令和工作目录会回显在下方 CLI 窗口。 405 | - 使用 `↑` / `↓` 键浏览命令历史。 406 | - 使用 `cd <目录>` 更改工作目录 (使用 `/cwd` 命令查看当前目录)。 407 | - 使用 `cls` (Win) 或 `clear` (Linux/Mac) 清空此窗口。 408 | - **按 `Tab` 键可在命令行和聊天输入框之间切换焦点。** 409 | """ 410 | commands_title = "**常用聊天命令:**" 411 | cmd_help = "/help 显示此帮助信息。" 412 | cmd_settings = "/settings 打开设置 (API密钥, 模型列表, 主题, CLI上下文等)。" 413 | cmd_clear = "/clear 清除聊天窗口及历史记录。" 414 | cmd_clear_cli = "/clear_cli 清除命令行窗口的输出。" 415 | cmd_clear_all = "/clear_all 同时清除聊天和命令行窗口。" 416 | cmd_cwd = "/cwd 在聊天中显示当前完整工作目录。" 417 | cmd_copy_cli = "/copy_cli 复制左侧 CLI 窗口的全部输出到剪贴板。" 418 | cmd_show_cli = "/show_cli [N] 在聊天中显示左侧 CLI 输出的最后 N 行 (默认 10)。" 419 | cmd_save = "/save 手动保存当前状态 (历史, 工作目录, 选择的模型)。" 420 | cmd_exit = "/exit 退出 {APP_NAME}。" 421 | toolbar_info_title = "**工具栏说明:**" 422 | toolbar_desc = (f"- 左侧: 设置按钮。\n- 右侧: 模型选择下拉框 | 状态指示灯(🟢空闲/🔴忙碌)。") 423 | help_text = (f"{help_title}\n\n{core_info}\n\n" 424 | f"{commands_title}\n" 425 | f" {cmd_help}\n" 426 | f" {cmd_settings}\n" 427 | f" {cmd_clear}\n" 428 | f" {cmd_clear_cli}\n" 429 | f" {cmd_clear_all}\n" 430 | f" {cmd_cwd}\n" 431 | f" {cmd_copy_cli}\n" 432 | f" {cmd_show_cli}\n" 433 | f" {cmd_save}\n" 434 | f" {cmd_exit}\n\n" 435 | f"{toolbar_info_title}\n{toolbar_desc}\n") 436 | # add_chat_message should have logging 437 | self.add_chat_message("Help", help_text, add_to_internal_history=False) 438 | 439 | 440 | def closeEvent(self, event): 441 | """Handles window close: stop threads, save state gracefully.""" 442 | logger.info("Close event triggered.") 443 | if self._closing: 444 | logger.warning("Close event ignored: Already closing.") 445 | event.ignore(); return 446 | 447 | self._closing = True 448 | logger.info("Initiating application shutdown sequence...") 449 | 450 | # Stop workers (methods should have own logging) 451 | logger.info("Stopping API worker thread (if running)...") 452 | api_stopped = self.stop_api_worker() 453 | logger.info("Stopping Manual Command worker thread (if running)...") 454 | manual_stopped = self.stop_manual_worker() 455 | 456 | # Wait for threads (optional, with timeout) 457 | wait_timeout_ms = 500; threads_to_wait = [] 458 | if api_stopped and self.api_worker_thread: threads_to_wait.append(self.api_worker_thread) 459 | if manual_stopped and self.manual_cmd_thread: threads_to_wait.append(self.manual_cmd_thread) 460 | 461 | if threads_to_wait: 462 | logger.info(f"Waiting up to {wait_timeout_ms}ms for {len(threads_to_wait)} worker thread(s) to finish...") 463 | start_wait_time = time.monotonic(); all_finished = False 464 | while time.monotonic() - start_wait_time < wait_timeout_ms / 1000.0: 465 | # Use isFinished() for QThread state check 466 | all_finished = all(thread.isFinished() for thread in threads_to_wait) 467 | if all_finished: break 468 | QApplication.processEvents(); QThread.msleep(50) 469 | 470 | if all_finished: logger.info("All worker threads finished gracefully.") 471 | else: logger.warning("Worker thread(s) did not finish within timeout.") 472 | else: 473 | logger.info("No active worker threads needed waiting.") 474 | 475 | # Save final state 476 | logger.info("Saving final application state before closing...") 477 | self.save_state() # Method has logging 478 | 479 | logger.info("Accepting close event. Exiting application.") 480 | event.accept() -------------------------------------------------------------------------------- /PowerAgent/gui/main_window_state.py: -------------------------------------------------------------------------------- 1 | # gui/main_window_state.py 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import json 6 | import traceback 7 | import logging # Import logging 8 | from collections import deque 9 | from typing import TYPE_CHECKING 10 | 11 | # Import necessary components from the project 12 | from core import config 13 | 14 | # Type hinting for MainWindow without causing circular import at runtime 15 | if TYPE_CHECKING: 16 | from .main_window import MainWindow 17 | 18 | # --- Get Logger --- 19 | logger = logging.getLogger(__name__) 20 | 21 | class StateMixin: 22 | """Mixin containing state saving/loading logic for MainWindow.""" 23 | 24 | def save_state(self: 'MainWindow'): 25 | """Saves chat history, CLI history, current directory, splitter state, and selected model.""" 26 | if self._closing: 27 | logger.info("Skipping save_state during close sequence.") 28 | return 29 | 30 | logger.info("Attempting to save application state...") 31 | try: 32 | settings = config.get_settings() # Get QSettings instance 33 | 34 | # --- Prepare Data --- 35 | # Make copies to avoid issues if original deques are modified during save 36 | history_list = list(self.conversation_history) 37 | cli_history_list = list(self.cli_command_history) 38 | current_directory_to_save = self.current_directory 39 | current_selected_model_to_save = config.CURRENTLY_SELECTED_MODEL_ID # Get from config module 40 | 41 | logger.debug("State to save:") 42 | logger.debug(f" Chat History Items: {len(history_list)}") 43 | logger.debug(f" CLI History Items: {len(cli_history_list)}") 44 | logger.debug(f" Current Directory: {current_directory_to_save}") 45 | logger.debug(f" Selected Model: {current_selected_model_to_save if current_selected_model_to_save else ''}") 46 | 47 | # --- Save UI/History State to QSettings --- 48 | logger.debug("Saving state group...") 49 | settings.beginGroup("state") 50 | settings.setValue("conversation_history", json.dumps(history_list)) 51 | settings.setValue("current_directory", current_directory_to_save) 52 | settings.setValue("cli_history", json.dumps(cli_history_list)) 53 | settings.endGroup() 54 | logger.debug("State group saved.") 55 | 56 | # --- Save UI Geometry/Splitter State --- 57 | if self.splitter: 58 | logger.debug("Saving UI group (splitter state)...") 59 | settings.beginGroup("ui") 60 | splitter_state = self.splitter.saveState() 61 | settings.setValue("splitter_state", splitter_state) 62 | settings.endGroup() 63 | logger.debug(f"Splitter state saved (Size: {len(splitter_state) if splitter_state else 0} bytes).") 64 | else: 65 | logger.warning("Splitter not found, cannot save its state.") 66 | 67 | # --- Save currently selected model via config.save_config --- 68 | # This might seem redundant, but ensures the selected model is stored 69 | # alongside other config settings managed by save_config. 70 | logger.debug("Updating config module with current selected model...") 71 | # Retrieve the rest of the config values to pass them back to save_config 72 | current_config_vals = config.get_current_config() 73 | try: 74 | config.save_config( 75 | api_key=current_config_vals["api_key"], 76 | api_url=current_config_vals["api_url"], 77 | model_id_string=current_config_vals["model_id_string"], 78 | auto_startup=current_config_vals["auto_startup"], 79 | theme=current_config_vals["theme"], 80 | include_cli_context=current_config_vals["include_cli_context"], 81 | include_timestamp=current_config_vals.get("include_timestamp_in_prompt", config.DEFAULT_INCLUDE_TIMESTAMP), 82 | enable_multi_step=current_config_vals["enable_multi_step"], 83 | multi_step_max_iterations=current_config_vals["multi_step_max_iterations"], 84 | auto_include_ui_info=current_config_vals["auto_include_ui_info"], 85 | selected_model_id=current_selected_model_to_save # Pass the specific value to be saved 86 | ) 87 | logger.debug("Selected model ID (%s) passed to config.save_config.", current_selected_model_to_save) 88 | except Exception as config_save_err: 89 | # Log error from config save, but don't let it stop state saving 90 | logger.error("Error occurred while calling config.save_config during state save.", exc_info=True) 91 | 92 | 93 | logger.info("Application state saved successfully.") 94 | 95 | except Exception as e: 96 | # Log the exception with traceback 97 | logger.error("Error saving application state.", exc_info=True) 98 | # Optionally display an error message to the user if critical 99 | 100 | def load_state(self: 'MainWindow'): 101 | """ 102 | Loads state on startup (CWD, chat history, CLI history). Logs the process. 103 | Sets self.current_directory based on saved state or falls back to self.initial_directory. 104 | The actual process CWD change happens later in __init__ using _sync_process_cwd. 105 | """ 106 | if self._closing: 107 | logger.info("Skipping load_state during close sequence.") 108 | return 109 | 110 | logger.info("Loading application state...") 111 | # --- Ensure default directories exist before loading --- 112 | if not hasattr(self, 'initial_directory') or not self.initial_directory: 113 | logger.error("CRITICAL: initial_directory not set before load_state. Cannot determine default CWD.") 114 | # Handle this critical error, maybe set a hardcoded default or raise exception 115 | # For now, set a placeholder to avoid crashing later code, but log the error. 116 | self.initial_directory = "." # Placeholder CWD 117 | 118 | if not hasattr(self, 'application_base_dir') or not self.application_base_dir: 119 | logger.warning("application_base_dir not set before load_state.") 120 | # This might be less critical than initial_directory 121 | 122 | try: 123 | settings = config.get_settings() 124 | # Default to the initial directory ('Space') if nothing valid is loaded 125 | restored_cwd = self.initial_directory 126 | logger.debug(f"Default CWD set to initial directory: {restored_cwd}") 127 | 128 | # --- Load State Group --- 129 | logger.debug("Loading state group...") 130 | settings.beginGroup("state") 131 | saved_cwd = settings.value("current_directory") # Load raw value first 132 | history_json = settings.value("conversation_history", "[]") 133 | cli_history_json = settings.value("cli_history", "[]") 134 | settings.endGroup() 135 | logger.debug("State group loaded.") 136 | 137 | # --- Load and Validate CWD --- 138 | logger.debug("Processing saved CWD...") 139 | if saved_cwd and isinstance(saved_cwd, str) and saved_cwd.strip(): 140 | normalized_saved_cwd = os.path.normpath(saved_cwd) 141 | logger.info(f"Found saved directory in settings: {normalized_saved_cwd}") 142 | if os.path.isdir(normalized_saved_cwd): # Check if saved directory exists and is a directory 143 | restored_cwd = normalized_saved_cwd 144 | logger.info(f"Saved directory is valid. Using: {restored_cwd}") 145 | else: 146 | logger.warning(f"Saved directory '{normalized_saved_cwd}' not found or invalid. Falling back to default '{self.initial_directory}'.") 147 | # restored_cwd remains self.initial_directory 148 | else: 149 | logger.info(f"No valid saved directory found in settings. Using default directory '{self.initial_directory}'.") 150 | # restored_cwd remains self.initial_directory 151 | 152 | # Set the internal current directory state based on loading result 153 | self.current_directory = restored_cwd 154 | logger.info(f"Internal CWD state set to: {self.current_directory} (OS chdir will be attempted later in __init__)") 155 | 156 | # --- Load Chat History --- 157 | logger.debug("Processing saved conversation history...") 158 | loaded_history = [] 159 | try: 160 | # Ensure json data is string before loading 161 | if not isinstance(history_json, str): history_json = str(history_json) 162 | history_list = json.loads(history_json) 163 | # Basic validation of history format 164 | if isinstance(history_list, list) and \ 165 | all(isinstance(item, (list, tuple)) and len(item) == 2 and 166 | isinstance(item[0], str) and isinstance(item[1], str) for item in history_list): 167 | loaded_history = history_list 168 | logger.info(f"Loaded {len(loaded_history)} conversation history items.") 169 | elif history_json and history_json != "[]": # Log only if non-empty but invalid 170 | logger.warning(f"Saved conversation history format invalid. JSON was: {history_json[:100]}...") 171 | else: 172 | logger.info("No conversation history found or history was empty.") 173 | except json.JSONDecodeError as json_err: 174 | logger.warning(f"Error decoding conversation history JSON: {json_err}. History will be empty.") 175 | except Exception as e: 176 | logger.error("Unexpected error processing saved conversation history.", exc_info=True) 177 | 178 | # Initialize conversation_history if it doesn't exist yet (defensive) 179 | if not hasattr(self, 'conversation_history') or not isinstance(self.conversation_history, deque): 180 | logger.warning("conversation_history deque not initialized before load_state. Creating new.") 181 | self.conversation_history = deque(maxlen=50) # Use constant or config value for maxlen ideally 182 | self.conversation_history.clear(); self.conversation_history.extend(loaded_history) 183 | logger.debug("conversation_history deque updated.") 184 | 185 | # --- Load CLI History --- 186 | logger.debug("Processing saved CLI history...") 187 | loaded_cli_history = [] 188 | try: 189 | if not isinstance(cli_history_json, str): cli_history_json = str(cli_history_json) 190 | cli_history_list = json.loads(cli_history_json) 191 | if isinstance(cli_history_list, list) and all(isinstance(item, str) for item in cli_history_list): 192 | loaded_cli_history = cli_history_list 193 | logger.info(f"Loaded {len(loaded_cli_history)} CLI history items.") 194 | elif cli_history_json and cli_history_json != "[]": # Log only if non-empty but invalid 195 | logger.warning(f"Saved CLI history format invalid. JSON was: {cli_history_json[:100]}...") 196 | else: 197 | logger.info("No CLI history found or history was empty.") 198 | except json.JSONDecodeError as json_err: 199 | logger.warning(f"Error decoding CLI history JSON: {json_err}. History will be empty.") 200 | except Exception as e: 201 | logger.error("Unexpected error processing saved CLI history.", exc_info=True) 202 | 203 | # Initialize cli_command_history if it doesn't exist yet (defensive) 204 | if not hasattr(self, 'cli_command_history') or not isinstance(self.cli_command_history, deque): 205 | logger.warning("cli_command_history deque not initialized before load_state. Creating new.") 206 | self.cli_command_history = deque(maxlen=100) # Use constant/config for maxlen 207 | self.cli_command_history.clear(); self.cli_command_history.extend(loaded_cli_history) 208 | self.cli_history_index = -1 # Reset navigation index 209 | logger.debug("cli_command_history deque updated and index reset.") 210 | 211 | logger.info("Application state loading process finished.") 212 | 213 | except Exception as e: 214 | # Log the critical error with traceback 215 | logger.critical("CRITICAL Error during application state loading.", exc_info=True) 216 | logger.warning("Resetting state variables to defaults due to loading error.") 217 | # Ensure deques exist before clearing (Defensive) 218 | if not hasattr(self, 'conversation_history') or not isinstance(self.conversation_history, deque): self.conversation_history = deque(maxlen=50) 219 | if not hasattr(self, 'cli_command_history') or not isinstance(self.cli_command_history, deque): self.cli_command_history = deque(maxlen=100) 220 | self.conversation_history.clear(); self.cli_command_history.clear(); self.cli_history_index = -1 221 | # Ensure CWD defaults to the 'Space' directory even after error 222 | self.current_directory = self.initial_directory 223 | logger.info(f"Internal CWD state reset to default due to error: {self.current_directory}") -------------------------------------------------------------------------------- /PowerAgent/gui/main_window_workers.py: -------------------------------------------------------------------------------- 1 | # gui/main_window_workers.py 2 | # -*- coding: utf-8 -*- 3 | 4 | import os 5 | import time 6 | import traceback 7 | import logging # Import logging 8 | from typing import TYPE_CHECKING 9 | 10 | from PySide6.QtWidgets import QApplication 11 | from PySide6.QtCore import Slot, QThread 12 | from PySide6.QtGui import QIcon 13 | 14 | 15 | # Import necessary components from the project 16 | from core import config 17 | from core.workers import ApiWorkerThread, ManualCommandThread 18 | 19 | # Type hinting for MainWindow without causing circular import at runtime 20 | if TYPE_CHECKING: 21 | from .main_window import MainWindow 22 | 23 | # --- Get Logger --- 24 | logger = logging.getLogger(__name__) 25 | 26 | class WorkersMixin: 27 | """Mixin containing worker interaction logic for MainWindow.""" 28 | 29 | def is_busy(self: 'MainWindow') -> bool: 30 | """Checks if either the API or Manual command worker is active.""" 31 | api_running = self.api_worker_thread and self.api_worker_thread.isRunning() 32 | manual_running = self.manual_cmd_thread and self.manual_cmd_thread.isRunning() 33 | # logger.debug(f"is_busy check: API running={api_running}, Manual running={manual_running}") # Too verbose 34 | return api_running or manual_running 35 | 36 | def start_api_worker(self: 'MainWindow', model_id: str, history: list, prompt: str): 37 | """Creates, connects, and starts the API worker thread.""" 38 | logger.info(f"Attempting to start ApiWorkerThread for model: {model_id}") 39 | if self.api_worker_thread and self.api_worker_thread.isRunning(): 40 | logger.warning("Tried to start API worker while one was already running. Ignoring.") 41 | return 42 | 43 | try: 44 | # Log parameters being passed (mask sensitive) 45 | logger.debug("ApiWorkerThread Parameters:") 46 | logger.debug(" Model ID: %s", model_id) 47 | logger.debug(" History Length: %d", len(history)) 48 | logger.debug(" Prompt Length: %d", len(prompt)) 49 | # logger.debug(" Prompt Preview: %s", prompt[:80] + "...") # Be careful with prompt content 50 | logger.debug(" CWD: %s", self.current_directory) 51 | 52 | self.api_worker_thread = ApiWorkerThread( 53 | api_key=config.API_KEY, # API Key is handled internally by worker 54 | api_url=config.API_URL, 55 | model_id=model_id, 56 | history=history, 57 | prompt=prompt, 58 | cwd=self.current_directory 59 | ) 60 | logger.debug("ApiWorkerThread instance created.") 61 | 62 | # --- Connect signals --- 63 | logger.debug("Connecting signals for ApiWorkerThread...") 64 | self.api_worker_thread.api_result.connect(self.handle_api_result) 65 | self.api_worker_thread.cli_output_signal.connect(lambda b: self.add_cli_output(b, "output")) 66 | self.api_worker_thread.cli_error_signal.connect(lambda b: self.add_cli_output(b, "error")) 67 | self.api_worker_thread.directory_changed_signal.connect(self.handle_directory_change) 68 | self.api_worker_thread.task_finished.connect(lambda: self.handle_task_finished("api")) 69 | self.api_worker_thread.ai_command_echo_signal.connect( 70 | lambda cmd: self.add_chat_message(role="AI Command", message=cmd, add_to_internal_history=False) 71 | ) 72 | logger.debug("ApiWorkerThread signals connected.") 73 | 74 | self.api_worker_thread.start() 75 | logger.info("ApiWorkerThread started successfully.") 76 | except Exception as e: 77 | logger.critical("Failed to create or start ApiWorkerThread!", exc_info=True) 78 | # Attempt to reset busy state if failed to start 79 | self.set_busy_state(False, "api") 80 | # Inform user? 81 | self.add_chat_message("Error", f"无法启动 AI 任务: {e}", add_to_internal_history=False) 82 | 83 | 84 | def start_manual_worker(self: 'MainWindow', command: str): 85 | """Creates, connects, and starts the Manual command worker thread.""" 86 | logger.info(f"Attempting to start ManualCommandThread for command: '{command}'") 87 | if self.manual_cmd_thread and self.manual_cmd_thread.isRunning(): 88 | logger.warning("Tried to start manual worker while one was already running. Ignoring.") 89 | return 90 | 91 | try: 92 | logger.debug("ManualCommandThread Parameters:") 93 | logger.debug(" Command: %s", command) 94 | logger.debug(" CWD: %s", self.current_directory) 95 | 96 | self.manual_cmd_thread = ManualCommandThread(command, self.current_directory) 97 | logger.debug("ManualCommandThread instance created.") 98 | 99 | # --- Connect signals --- 100 | logger.debug("Connecting signals for ManualCommandThread...") 101 | self.manual_cmd_thread.cli_output_signal.connect(lambda b: self.add_cli_output(b, "output")) 102 | self.manual_cmd_thread.cli_error_signal.connect(lambda b: self.add_cli_output(b, "error")) 103 | self.manual_cmd_thread.directory_changed_signal.connect(self.handle_directory_change) 104 | self.manual_cmd_thread.command_finished.connect(lambda: self.handle_task_finished("manual")) 105 | logger.debug("ManualCommandThread signals connected.") 106 | 107 | self.manual_cmd_thread.start() 108 | logger.info("ManualCommandThread started successfully.") 109 | except Exception as e: 110 | logger.critical("Failed to create or start ManualCommandThread!", exc_info=True) 111 | # Attempt to reset busy state if failed to start 112 | self.set_busy_state(False, "manual") 113 | # Inform user? 114 | self.add_cli_output(f"无法启动命令任务: {e}".encode('utf-8'), "error") 115 | 116 | 117 | def set_busy_state(self: 'MainWindow', busy: bool, task_type: str): 118 | """Updates UI element states and the status indicator based on task activity.""" 119 | logger.info(f"Setting busy state: Busy={busy}, TaskType='{task_type}'") 120 | if self._closing: logger.warning("Skipping set_busy_state during close sequence."); return 121 | 122 | try: 123 | # Determine the *next* overall busy state 124 | # Check current running state *before* applying the new state 125 | is_api_currently_busy = self.api_worker_thread and self.api_worker_thread.isRunning() 126 | is_manual_currently_busy = self.manual_cmd_thread and self.manual_cmd_thread.isRunning() 127 | 128 | # Calculate if API *will be* busy after this change 129 | next_api_busy = (is_api_currently_busy and not (task_type == "api" and not busy)) or \ 130 | (task_type == "api" and busy) 131 | # Calculate if Manual *will be* busy after this change 132 | next_manual_busy = (is_manual_currently_busy and not (task_type == "manual" and not busy)) or \ 133 | (task_type == "manual" and busy) 134 | 135 | logger.debug(f"Busy state calculation: CurrentAPI={is_api_currently_busy}, CurrentManual={is_manual_currently_busy} -> NextAPI={next_api_busy}, NextManual={next_manual_busy}") 136 | 137 | # --- Update Enabled State of UI Elements --- 138 | logger.debug("Updating UI element enabled states...") 139 | if self.chat_input: self.chat_input.setEnabled(not next_api_busy) 140 | if self.clear_chat_button: self.clear_chat_button.setEnabled(not next_api_busy) 141 | can_enable_model_selector = not next_api_busy and \ 142 | self.model_selector_combo and \ 143 | self.model_selector_combo.count() > 0 and \ 144 | self.model_selector_combo.itemText(0) != "未配置模型" 145 | if self.model_selector_combo: self.model_selector_combo.setEnabled(can_enable_model_selector); logger.debug(f"ModelSelector enabled: {can_enable_model_selector}") 146 | 147 | if self.cli_input: self.cli_input.setEnabled(not next_manual_busy); logger.debug(f"CliInput enabled: {not next_manual_busy}") 148 | if self.clear_cli_button: self.clear_cli_button.setEnabled(not next_manual_busy) 149 | 150 | # --- Update Send/Stop Button Appearance and Tooltip --- 151 | if self.send_button: 152 | logger.debug("Updating Send/Stop button...") 153 | if next_api_busy: 154 | stop_icon = self._get_icon("process-stop", "stop.png", "⏹️") 155 | self.send_button.setText("停止") 156 | self.send_button.setIcon(stop_icon if not stop_icon.isNull() else QIcon()) 157 | self.send_button.setToolTip("停止当前正在运行的 AI 任务") 158 | self.send_button.setEnabled(True) # Stop button is always enabled when busy 159 | logger.debug("Set button to STOP state.") 160 | else: 161 | send_icon = self._get_icon("mail-send", "send.png", "▶️") 162 | self.send_button.setText("发送") 163 | self.send_button.setIcon(send_icon if not send_icon.isNull() else QIcon()) 164 | self.send_button.setToolTip("向 AI 发送消息 (Shift+Enter 换行)") 165 | api_configured = bool(config.API_KEY and config.API_URL and config.MODEL_ID_STRING and self.model_selector_combo and self.model_selector_combo.currentText() != "未配置模型") 166 | self.send_button.setEnabled(api_configured) 167 | logger.debug(f"Set button to SEND state (Enabled: {api_configured}).") 168 | else: 169 | logger.warning("Send/Stop button not found, cannot update state.") 170 | 171 | 172 | # --- Update Status Indicator --- 173 | indicator_busy_state = next_api_busy or next_manual_busy 174 | logger.debug(f"Updating status indicator to busy={indicator_busy_state}") 175 | self.update_status_indicator(indicator_busy_state) # Method logs details 176 | 177 | # --- Set Focus --- 178 | # Set focus back only when transitioning from busy to not busy *overall* 179 | is_currently_busy_overall = is_api_currently_busy or is_manual_currently_busy 180 | is_next_busy_overall = next_api_busy or next_manual_busy 181 | if is_currently_busy_overall and not is_next_busy_overall: 182 | logger.info(f"Task '{task_type}' finished, transitioning to idle state. Setting focus...") 183 | QApplication.processEvents() # Allow UI to update before setting focus 184 | focused = False 185 | # Prioritize focus based on which task just finished 186 | if task_type == "api" and self.chat_input and self.chat_input.isEnabled(): 187 | self.chat_input.setFocus(); focused = True; logger.debug("Set focus to chat input.") 188 | elif task_type == "manual" and self.cli_input and self.cli_input.isEnabled(): 189 | self.cli_input.setFocus(); focused = True; logger.debug("Set focus to CLI input.") 190 | # Fallback focus logic if primary target isn't available/enabled 191 | elif not focused and self.chat_input and self.chat_input.isEnabled(): 192 | self.chat_input.setFocus(); logger.debug("Set focus to chat input (fallback).") 193 | elif not focused and self.cli_input and self.cli_input.isEnabled(): 194 | self.cli_input.setFocus(); logger.debug("Set focus to CLI input (fallback).") 195 | else: 196 | logger.warning("Could not set focus after task finished - no suitable input widget enabled.") 197 | elif not busy: # Task finished, but another might still be running 198 | logger.debug(f"Task '{task_type}' finished, but another task is still running. Not setting focus.") 199 | # Else: Transitioning to busy state, focus handled elsewhere (e.g., user input) 200 | 201 | except Exception as e: 202 | logger.error("Error occurred during set_busy_state.", exc_info=True) 203 | 204 | 205 | @Slot(str, float) 206 | def handle_api_result(self: 'MainWindow', reply: str, elapsed_time: float): 207 | """Handles the text result from the API worker thread.""" 208 | logger.info(f"Handling API result (Elapsed: {elapsed_time:.2f}s). Reply preview: '{reply[:100]}...'") 209 | if self._closing: logger.warning("Ignoring API result: application is closing."); return 210 | 211 | try: 212 | # Add the result using the "Model" role (method logs details) 213 | self.add_chat_message("Model", reply, add_to_internal_history=True, elapsed_time=elapsed_time) 214 | logger.debug("API result added to chat display.") 215 | except Exception as e: 216 | logger.error("Error handling API result.", exc_info=True) 217 | 218 | 219 | @Slot(str, bool) 220 | def handle_directory_change(self: 'MainWindow', new_directory: str, is_manual_command: bool): 221 | """Handles directory change signaled by workers (after 'cd' command).""" 222 | source = "Manual Command Worker" if is_manual_command else "API Worker" 223 | logger.info(f"Handling directory change signal from {source}: New directory='{new_directory}'") 224 | if self._closing: logger.warning("Ignoring directory change: application is closing."); return 225 | 226 | try: 227 | if os.path.isdir(new_directory): 228 | old_directory = self.current_directory 229 | normalized_new_dir = os.path.normpath(new_directory) 230 | if old_directory != normalized_new_dir: 231 | logger.info(f"Updating internal CWD state from '{old_directory}' to '{normalized_new_dir}'.") 232 | self.current_directory = normalized_new_dir 233 | # Attempt to change the actual process CWD (log inside method) 234 | self._sync_process_cwd() 235 | # Update UI prompt (log inside method) 236 | self.update_prompt() 237 | # Save the new state (log inside method) 238 | self.save_state() 239 | else: 240 | logger.debug(f"Directory change signal received, but new directory ('{normalized_new_dir}') matches current internal state. No update needed.") 241 | else: 242 | logger.error(f"Directory change failed: Path '{new_directory}' received from worker is not a valid directory.") 243 | # Inform user via CLI error 244 | self._emit_cli_error(f"Worker reported invalid directory change: '{new_directory}'") # Use helper 245 | 246 | except Exception as e: 247 | logger.error("Error handling directory change signal.", exc_info=True) 248 | self._emit_cli_error(f"Error processing directory change: {e}") 249 | 250 | 251 | @Slot(str) 252 | def handle_task_finished(self: 'MainWindow', task_type: str): 253 | """Handles finished signal from worker threads (API or Manual).""" 254 | logger.info(f"{task_type.capitalize()} worker thread finished signal received.") 255 | if self._closing: logger.warning(f"Ignoring task finished signal for '{task_type}': application is closing."); return 256 | 257 | try: 258 | # Set UI state to not busy for the completed task type 259 | self.set_busy_state(False, task_type) # Method logs details 260 | 261 | # Clear the reference to the finished thread 262 | if task_type == "api": 263 | if self.api_worker_thread: 264 | logger.debug("Clearing reference to finished ApiWorkerThread.") 265 | self.api_worker_thread = None 266 | else: 267 | logger.warning("Received API task finished signal, but no active thread reference found.") 268 | elif task_type == "manual": 269 | if self.manual_cmd_thread: 270 | logger.debug("Clearing reference to finished ManualCommandThread.") 271 | self.manual_cmd_thread = None 272 | else: 273 | logger.warning("Received Manual task finished signal, but no active thread reference found.") 274 | else: 275 | logger.warning(f"Received task finished signal for unknown task type: '{task_type}'") 276 | 277 | except Exception as e: 278 | logger.error(f"Error handling task finished signal for '{task_type}'.", exc_info=True) 279 | 280 | def stop_api_worker(self: 'MainWindow'): 281 | """Signals the API worker thread to stop.""" 282 | logger.info("Attempting to stop API worker thread...") 283 | if self.api_worker_thread and self.api_worker_thread.isRunning(): 284 | logger.debug("API worker is running, calling stop().") 285 | try: 286 | self.api_worker_thread.stop() # Call the thread's stop method (thread logs internally) 287 | logger.info("API worker stop signal sent.") 288 | return True # Indicate that stop was called 289 | except Exception as e: 290 | logger.error("Error trying to call stop() on API worker.", exc_info=True) 291 | return False 292 | elif self.api_worker_thread: 293 | logger.info("API worker thread exists but is not running.") 294 | return False 295 | else: 296 | logger.info("No active API worker thread found to stop.") 297 | return False 298 | 299 | def stop_manual_worker(self: 'MainWindow'): 300 | """Signals the manual command worker thread to stop.""" 301 | logger.info("Attempting to stop Manual Command worker thread...") 302 | if self.manual_cmd_thread and self.manual_cmd_thread.isRunning(): 303 | logger.debug("Manual worker is running, calling stop().") 304 | try: 305 | self.manual_cmd_thread.stop() # Thread logs internally 306 | logger.info("Manual worker stop signal sent.") 307 | return True 308 | except Exception as e: 309 | logger.error("Error trying to call stop() on manual worker.", exc_info=True) 310 | return False 311 | elif self.manual_cmd_thread: 312 | logger.info("Manual worker thread exists but is not running.") 313 | return False 314 | else: 315 | logger.info("No active manual command worker thread found to stop.") 316 | return False 317 | 318 | # Helper to emit CLI error safely from this mixin if needed 319 | def _emit_cli_error(self: 'MainWindow', message: str): 320 | try: 321 | if not isinstance(message, str): message = str(message) 322 | logger.debug(f"Emitting direct CLI error from WorkersMixin: {message}") 323 | # Assuming MainWindow instance has access to cli_error_signal via ApiWorkerThread or similar mechanism 324 | # This is slightly indirect. A better approach might be a dedicated signal on MainWindow itself. 325 | # For now, let's assume we can access a signal. If ApiWorkerThread exists, use its signal. 326 | # If not, maybe log and skip? 327 | if self.cli_error_signal: # Check if the signal exists directly on the mixin/main window instance 328 | self.cli_error_signal.emit(f"[Handler Error] {message}".encode('utf-8')) 329 | elif self.api_worker_thread and hasattr(self.api_worker_thread, 'cli_error_signal'): 330 | self.api_worker_thread.cli_error_signal.emit(f"[Handler Error] {message}".encode('utf-8')) 331 | else: 332 | logger.warning(f"Cannot emit CLI error from WorkersMixin - no suitable signal found. Message: {message}") 333 | 334 | except RuntimeError: logger.warning("Cannot emit direct CLI error, target likely deleted.") 335 | except Exception as e: logger.error("Error emitting direct CLI error.", exc_info=True) -------------------------------------------------------------------------------- /PowerAgent/gui/palette.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/gui/palette.py 3 | # (No changes needed in this file for this feature) 4 | # ----------------------------------------------------------------------- 5 | # gui/palette.py 6 | # -*- coding: utf-8 -*- 7 | 8 | from PySide6.QtGui import QPalette, QColor 9 | from PySide6.QtCore import Qt 10 | from PySide6.QtWidgets import QApplication 11 | 12 | # Import get_color (though it might be less used now for system theme) 13 | from constants import get_color 14 | 15 | # --- Dark Theme --- 16 | def setup_dark_palette(app: QApplication): 17 | """Configures and applies a dark Fusion style palette to the QApplication.""" 18 | app.setStyle("Fusion") # Ensure Fusion style is set for dark theme 19 | dark_palette = QPalette() 20 | theme = "dark" 21 | 22 | # Define colors using get_color for consistency with other parts 23 | COLOR_WINDOW_BG = QColor(53, 53, 53) 24 | COLOR_BASE_BG = QColor(42, 42, 42) 25 | COLOR_TEXT = get_color("text_main", theme) 26 | COLOR_BUTTON_BG = QColor(70, 70, 70) 27 | COLOR_HIGHLIGHT = QColor(42, 130, 218) 28 | COLOR_HIGHLIGHTED_TEXT = QColor(255, 255, 255) 29 | COLOR_DISABLED_TEXT = QColor(127, 127, 127) 30 | 31 | # General Colors 32 | dark_palette.setColor(QPalette.ColorRole.Window, COLOR_WINDOW_BG) 33 | dark_palette.setColor(QPalette.ColorRole.WindowText, COLOR_TEXT) 34 | dark_palette.setColor(QPalette.ColorRole.Base, COLOR_BASE_BG) 35 | dark_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(60, 60, 60)) 36 | dark_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(20, 20, 20)) 37 | dark_palette.setColor(QPalette.ColorRole.ToolTipText, COLOR_TEXT) 38 | dark_palette.setColor(QPalette.ColorRole.Text, COLOR_TEXT) 39 | dark_palette.setColor(QPalette.ColorRole.Button, COLOR_BUTTON_BG) 40 | dark_palette.setColor(QPalette.ColorRole.ButtonText, COLOR_TEXT) 41 | dark_palette.setColor(QPalette.ColorRole.BrightText, get_color("error", theme)) 42 | dark_palette.setColor(QPalette.ColorRole.Link, COLOR_HIGHLIGHT) 43 | dark_palette.setColor(QPalette.ColorRole.Highlight, COLOR_HIGHLIGHT) 44 | dark_palette.setColor(QPalette.ColorRole.HighlightedText, COLOR_HIGHLIGHTED_TEXT) 45 | 46 | # Disabled Colors 47 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, COLOR_DISABLED_TEXT) 48 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, COLOR_DISABLED_TEXT) 49 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, COLOR_DISABLED_TEXT) 50 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, COLOR_WINDOW_BG) 51 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, QColor(53,53,53)) 52 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Highlight, QColor(80, 80, 80)) 53 | dark_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, COLOR_DISABLED_TEXT) 54 | 55 | app.setPalette(dark_palette) 56 | print("Dark theme global palette applied.") 57 | 58 | 59 | # --- Light Theme --- 60 | def setup_light_palette(app: QApplication): 61 | """Configures and applies a light Fusion style palette to the QApplication.""" 62 | app.setStyle("Fusion") # Ensure Fusion style is set for light theme 63 | light_palette = QPalette() 64 | theme = "light" 65 | 66 | # Use standard light theme colors or get from constants if needed 67 | COLOR_WINDOW_BG = QColor(240, 240, 240) 68 | COLOR_BASE_BG = QColor(240, 240, 240) # Modified light gray base 69 | COLOR_TEXT = get_color("text_main", theme) # Black 70 | COLOR_BUTTON_BG = QColor(225, 225, 225) 71 | COLOR_HIGHLIGHT = QColor(51, 153, 255) 72 | COLOR_HIGHLIGHTED_TEXT = QColor(255, 255, 255) 73 | COLOR_DISABLED_TEXT = QColor(160, 160, 160) 74 | 75 | # General Colors 76 | light_palette.setColor(QPalette.ColorRole.Window, COLOR_WINDOW_BG) 77 | light_palette.setColor(QPalette.ColorRole.WindowText, COLOR_TEXT) 78 | light_palette.setColor(QPalette.ColorRole.Base, COLOR_BASE_BG) 79 | light_palette.setColor(QPalette.ColorRole.AlternateBase, QColor(233, 233, 233)) 80 | light_palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(255, 255, 220)) 81 | light_palette.setColor(QPalette.ColorRole.ToolTipText, COLOR_TEXT) 82 | light_palette.setColor(QPalette.ColorRole.Text, COLOR_TEXT) 83 | light_palette.setColor(QPalette.ColorRole.Button, COLOR_BUTTON_BG) 84 | light_palette.setColor(QPalette.ColorRole.ButtonText, COLOR_TEXT) 85 | light_palette.setColor(QPalette.ColorRole.BrightText, get_color("error", theme)) 86 | light_palette.setColor(QPalette.ColorRole.Link, COLOR_HIGHLIGHT) 87 | light_palette.setColor(QPalette.ColorRole.Highlight, COLOR_HIGHLIGHT) 88 | light_palette.setColor(QPalette.ColorRole.HighlightedText, COLOR_HIGHLIGHTED_TEXT) 89 | 90 | # Disabled Colors 91 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.WindowText, COLOR_DISABLED_TEXT) 92 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Text, COLOR_DISABLED_TEXT) 93 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.ButtonText, COLOR_DISABLED_TEXT) 94 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Base, COLOR_BASE_BG) # Match modified base 95 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Button, QColor(225, 225, 225)) 96 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.Highlight, QColor(190, 190, 190)) 97 | light_palette.setColor(QPalette.ColorGroup.Disabled, QPalette.ColorRole.HighlightedText, COLOR_DISABLED_TEXT) 98 | 99 | app.setPalette(light_palette) 100 | print("Light theme global palette applied (with gray base background).") 101 | 102 | 103 | # --- Unified Setup Function --- 104 | # <<< 修改: 处理 "system" 主题 >>> 105 | def setup_palette(app: QApplication, theme_name: str = "system"): 106 | """Applies the specified theme palette (dark, light, or system default).""" 107 | # Set a consistent style first. Fusion is a good cross-platform choice. 108 | # Removing this might make it use the *native* system style (e.g., Windows style on Win), 109 | # which might be desirable for a true "system" feel, but can vary visually. 110 | # Let's keep Fusion for now for predictable behavior across platforms. 111 | app.setStyle("Fusion") 112 | 113 | if theme_name == "light": 114 | setup_light_palette(app) 115 | elif theme_name == "dark": 116 | setup_dark_palette(app) 117 | else: # Handle "system" or any other unknown value 118 | if theme_name != "system": 119 | print(f"Warning: Unknown theme '{theme_name}' requested. Using system default palette.") 120 | # For the system theme, we *don't* call app.setPalette() with our custom one. 121 | # Qt's default behavior when a style is set but no specific palette is applied 122 | # is to derive a palette appropriate for that style and the system's settings. 123 | # We can explicitly reset it to the application's default, but often just not setting it works. 124 | # app.setPalette(QApplication.style().standardPalette()) # Alternative: Explicit reset 125 | print("Using system default palette (no custom palette applied).") -------------------------------------------------------------------------------- /PowerAgent/gui/settings_dialog.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/gui/settings_dialog.py 3 | # (MODIFIED - Added CheckBox for Auto Include UI Info) 4 | # --------------------------------------- 5 | # gui/settings_dialog.py 6 | # -*- coding: utf-8 -*- 7 | 8 | import platform 9 | import os 10 | from PySide6.QtWidgets import ( 11 | QDialog, QLineEdit, QPushButton, QVBoxLayout, QFormLayout, 12 | QDialogButtonBox, QCheckBox, QLabel, QHBoxLayout, 13 | QComboBox, QSizePolicy, QSpacerItem, QGroupBox, QMessageBox, 14 | QSpinBox 15 | ) 16 | from PySide6.QtCore import QStandardPaths, QCoreApplication, Qt, QSize 17 | from PySide6.QtGui import QIcon 18 | 19 | # Import constants needed for paths/names 20 | from constants import SETTINGS_APP_NAME, ORG_NAME 21 | # Import config functions/state 22 | from core import config 23 | 24 | class SettingsDialog(QDialog): 25 | def __init__(self, parent=None): 26 | super().__init__(parent) 27 | self.setWindowTitle("应用程序设置") 28 | self.setModal(True) 29 | self.setMinimumWidth(500) # Increased width slightly for new options 30 | 31 | # Load current global settings initially to populate fields 32 | self.load_initial_values() 33 | 34 | # --- Widgets --- 35 | self.url_input = QLineEdit(self._current_api_url) 36 | self.url_input.setPlaceholderText("例如: https://api.openai.com/v1") # Example with /v1 37 | 38 | self.key_input = QLineEdit(self._current_api_key) 39 | self.key_input.setPlaceholderText("输入您的 API 密钥") 40 | self.key_input.setEchoMode(QLineEdit.EchoMode.Password) 41 | 42 | self.model_input = QLineEdit(self._current_model_id_string) 43 | self.model_input.setPlaceholderText("例如: gpt-4-turbo,claude-3-opus (逗号分隔)") 44 | 45 | self.show_hide_button = QPushButton() 46 | self.show_hide_button.setCheckable(True); self.show_hide_button.setChecked(False) 47 | self.show_hide_button.setFlat(True) # Make it look like an icon button 48 | self.show_hide_button.setToolTip("显示/隐藏 API 密钥") 49 | self.show_hide_button.setIconSize(QSize(16, 16)); self._update_visibility_icon(False) 50 | self.show_hide_button.clicked.connect(self.toggle_api_key_visibility) 51 | # Adjust size policy to hug the icon 52 | self.show_hide_button.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) 53 | 54 | 55 | key_layout = QHBoxLayout(); key_layout.setContentsMargins(0, 0, 0, 0); key_layout.setSpacing(3) 56 | key_layout.addWidget(self.key_input, 1); key_layout.addWidget(self.show_hide_button) 57 | 58 | self.theme_combobox = QComboBox() 59 | self.theme_combobox.addItem("系统默认 (System)", "system"); self.theme_combobox.addItem("暗色 (Dark)", "dark"); self.theme_combobox.addItem("亮色 (Light)", "light") 60 | current_theme_index = self.theme_combobox.findData(self._current_theme) 61 | self.theme_combobox.setCurrentIndex(current_theme_index if current_theme_index != -1 else 0) 62 | 63 | self.auto_startup_checkbox = QCheckBox("系统登录时启动") 64 | self.auto_startup_checkbox.setChecked(self._current_auto_startup) 65 | 66 | self.include_cli_context_checkbox = QCheckBox("自动将近期 CLI 输出作为上下文发送给 AI") 67 | self.include_cli_context_checkbox.setChecked(self._current_include_cli_context) 68 | self.include_cli_context_checkbox.setToolTip( 69 | "启用后,每次向 AI 发送消息时,会自动附带左侧 CLI 输出的**全部**内容。\n" 70 | "这有助于 AI 理解当前状态,但也可能显著增加 API Token 消耗,尤其是在 CLI 输出很长时。" 71 | ) 72 | 73 | self.include_timestamp_checkbox = QCheckBox("在系统提示词中包含当前日期时间") 74 | self.include_timestamp_checkbox.setChecked(self._current_include_timestamp) 75 | self.include_timestamp_checkbox.setToolTip( 76 | "启用后,每次向 AI 发送消息时,会在系统提示词中加入当前的日期和时间(精确到秒)。\n" 77 | "这可能对需要时间信息的任务有帮助,但会略微增加提示词长度。" 78 | ) 79 | 80 | # --- Multi-Step Options --- 81 | self.enable_multi_step_checkbox = QCheckBox("启用 AI 连续操作模式 (实验性)") 82 | self.enable_multi_step_checkbox.setChecked(self._current_enable_multi_step) 83 | self.enable_multi_step_checkbox.setToolTip( 84 | "**实验性功能:** 启用后,AI 可以执行多步骤任务。\n" 85 | "工作流程:\n" 86 | "1. AI 返回一个操作 (, , , )。\n" # Added get_ui_info 87 | "2. 应用执行该操作。\n" 88 | "3. 应用将操作结果/获取的信息作为系统消息添加到历史记录中。\n" 89 | "4. 应用**自动再次调用 AI**。\n" 90 | "5. AI 根据上一步的结果决定下一步操作或提供最终文本回复。\n" 91 | "**风险:** 可能导致意外行为、API 成本增加或陷入循环(有最大次数限制)。\n" 92 | "禁用此选项将恢复为单次问答模式。" 93 | ) 94 | 95 | self.max_iterations_spinbox = QSpinBox() 96 | self.max_iterations_spinbox.setMinimum(1) # Minimum 1 iteration 97 | self.max_iterations_spinbox.setMaximum(20) # Set a reasonable maximum 98 | self.max_iterations_spinbox.setValue(self._current_multi_step_max_iterations) 99 | self.max_iterations_spinbox.setToolTip( 100 | "设置“连续操作模式”下,AI 自动连续执行操作的最大次数。\n" 101 | "用于防止无限循环和控制 API 成本。\n" 102 | "推荐值: 3-10。" 103 | ) 104 | # Enable/disable based on multi-step checkbox state initially and on toggle 105 | self.max_iterations_spinbox.setEnabled(self._current_enable_multi_step) 106 | self.enable_multi_step_checkbox.toggled.connect(self.max_iterations_spinbox.setEnabled) 107 | 108 | # <<< MODIFICATION START: Add CheckBox for Auto UI Info >>> 109 | self.auto_include_ui_checkbox = QCheckBox("自动附加活动窗口 UI 结构信息 (实验性, 仅 Windows)") 110 | self.auto_include_ui_checkbox.setChecked(self._current_auto_include_ui_info) 111 | self.auto_include_ui_checkbox.setToolTip( 112 | "**实验性功能 (仅 Windows):** 启用后,每次向 AI 发送消息时,会自动尝试获取当前活动窗口的 UI 元素结构信息,并将其附加到上下文中。\n" 113 | "这可以帮助 AI 更精确地定位 GUI 元素 (使用 ),但也可能:\n" 114 | "- 显著增加 API Token 消耗。\n" 115 | "- 增加 AI 响应延迟。\n" 116 | "- 在某些复杂窗口中获取信息失败或不准确。\n" 117 | "禁用时,AI 只能基于文本历史和通用知识猜测 UI 元素,或通过 主动请求。" 118 | ) 119 | # Disable this checkbox if not on Windows or if uiautomation is not available 120 | is_gui_available = platform.system() == "Windows" and getattr(config, 'UIAUTOMATION_AVAILABLE_FOR_GUI', False) 121 | self.auto_include_ui_checkbox.setEnabled(is_gui_available) 122 | if not is_gui_available: 123 | self.auto_include_ui_checkbox.setToolTip(self.auto_include_ui_checkbox.toolTip() + "\n\n(此选项在此系统或配置下不可用)") 124 | # <<< MODIFICATION END >>> 125 | 126 | 127 | self.error_label = QLabel("") 128 | self.error_label.setStyleSheet("color: red; padding-top: 5px;"); self.error_label.setAlignment(Qt.AlignmentFlag.AlignCenter) 129 | self.error_label.setWordWrap(True); self.error_label.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed) 130 | self.error_label.setVisible(False) 131 | 132 | # --- Layout --- 133 | api_groupbox = QGroupBox("API 配置"); api_layout = QFormLayout(api_groupbox) 134 | api_layout.setRowWrapPolicy(QFormLayout.RowWrapPolicy.WrapLongRows); api_layout.setLabelAlignment(Qt.AlignmentFlag.AlignRight); api_layout.setSpacing(10) 135 | api_layout.addRow("API URL:", self.url_input); api_layout.addRow("API 密钥:", key_layout) 136 | api_layout.addRow("模型 ID (逗号分隔):", self.model_input) # Clarified label 137 | 138 | # UI & Behavior Group 139 | ui_groupbox = QGroupBox("界面与行为"); ui_layout = QVBoxLayout(ui_groupbox) 140 | ui_layout.setSpacing(10) 141 | 142 | theme_layout = QHBoxLayout() 143 | theme_layout.addWidget(QLabel("界面主题:")) 144 | theme_layout.addWidget(self.theme_combobox, 1) 145 | ui_layout.addLayout(theme_layout) 146 | 147 | ui_layout.addWidget(self.auto_startup_checkbox) 148 | ui_layout.addWidget(self.include_cli_context_checkbox) 149 | ui_layout.addWidget(self.include_timestamp_checkbox) 150 | 151 | # AI Behavior Sub-Group (Optional visual grouping) 152 | ai_behavior_group = QGroupBox("AI 行为 (实验性)") 153 | ai_behavior_layout = QVBoxLayout(ai_behavior_group) 154 | ai_behavior_layout.setSpacing(8) # Slightly less spacing inside 155 | 156 | ai_behavior_layout.addWidget(self.enable_multi_step_checkbox) 157 | 158 | iterations_layout = QHBoxLayout() 159 | iterations_layout.setContentsMargins(15, 0, 0, 0) # Indent spinbox relative to checkbox 160 | iterations_layout.addWidget(QLabel("连续操作最大次数:")) # Label next to spinbox 161 | iterations_layout.addWidget(self.max_iterations_spinbox) 162 | iterations_layout.addStretch(1) # Push spinbox to the left 163 | ai_behavior_layout.addLayout(iterations_layout) 164 | 165 | # <<< MODIFICATION START: Add Auto UI Info checkbox to AI group >>> 166 | ai_behavior_layout.addWidget(self.auto_include_ui_checkbox) 167 | # <<< MODIFICATION END >>> 168 | 169 | ui_layout.addWidget(ai_behavior_group) # Add the sub-group to the main UI layout 170 | 171 | # --- Reset Button --- 172 | self.reset_button = QPushButton("恢复默认设置并清除缓存") 173 | self.reset_button.setObjectName("reset_button") 174 | self.reset_button.setToolTip("将所有设置恢复为默认值,并清除聊天历史、命令历史和保存的工作目录。\n此操作无法撤销,需要确认。") 175 | self.reset_button.clicked.connect(self.handle_reset_settings) 176 | reset_layout = QHBoxLayout(); reset_layout.addWidget(self.reset_button); reset_layout.addStretch(1) 177 | 178 | # --- Standard Buttons --- 179 | button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Ok | QDialogButtonBox.StandardButton.Cancel) 180 | button_box.button(QDialogButtonBox.StandardButton.Ok).setText("确定") 181 | button_box.button(QDialogButtonBox.StandardButton.Cancel).setText("取消") 182 | button_box.accepted.connect(self.validate_and_accept) 183 | button_box.rejected.connect(self.reject) 184 | 185 | # --- Main Layout --- 186 | main_layout = QVBoxLayout(self) 187 | main_layout.addWidget(api_groupbox) 188 | main_layout.addWidget(ui_groupbox) 189 | main_layout.addLayout(reset_layout) # Reset button above error label 190 | main_layout.addWidget(self.error_label) # Error label at bottom before buttons 191 | # Removed the expanding spacer, let the groups take available space 192 | # main_layout.addSpacerItem(QSpacerItem(20, 10, QSizePolicy.Policy.Minimum, QSizePolicy.Policy.Expanding)) 193 | main_layout.addWidget(button_box) 194 | 195 | def load_initial_values(self): 196 | """Loads current config values into internal variables for UI population.""" 197 | current_config_values = config.get_current_config() 198 | self._current_api_key = current_config_values.get("api_key", config.DEFAULT_API_KEY) 199 | self._current_api_url = current_config_values.get("api_url", config.DEFAULT_API_URL) 200 | self._current_model_id_string = current_config_values.get("model_id_string", config.DEFAULT_MODEL_ID_STRING) 201 | self._current_auto_startup = current_config_values.get("auto_startup", config.DEFAULT_AUTO_STARTUP_ENABLED) 202 | self._current_theme = current_config_values.get("theme", config.DEFAULT_APP_THEME) 203 | self._current_include_cli_context = current_config_values.get("include_cli_context", config.DEFAULT_INCLUDE_CLI_CONTEXT) 204 | self._current_include_timestamp = current_config_values.get("include_timestamp_in_prompt", config.DEFAULT_INCLUDE_TIMESTAMP) 205 | self._current_enable_multi_step = current_config_values.get("enable_multi_step", config.DEFAULT_ENABLE_MULTI_STEP) 206 | self._current_multi_step_max_iterations = current_config_values.get("multi_step_max_iterations", config.DEFAULT_MULTI_STEP_MAX_ITERATIONS) 207 | # <<< MODIFICATION START: Load auto UI info value >>> 208 | self._current_auto_include_ui_info = current_config_values.get("auto_include_ui_info", config.DEFAULT_AUTO_INCLUDE_UI_INFO) 209 | # <<< MODIFICATION END >>> 210 | 211 | 212 | def update_fields_from_config(self): 213 | """Updates the UI fields based on the current config module state (e.g., after reset).""" 214 | self.load_initial_values() # Reload from config module first 215 | self.url_input.setText(self._current_api_url) 216 | self.key_input.setText(self._current_api_key) 217 | self.model_input.setText(self._current_model_id_string) 218 | self.auto_startup_checkbox.setChecked(self._current_auto_startup) 219 | current_theme_index = self.theme_combobox.findData(self._current_theme) 220 | self.theme_combobox.setCurrentIndex(current_theme_index if current_theme_index != -1 else 0) 221 | self.include_cli_context_checkbox.setChecked(self._current_include_cli_context) 222 | self.include_timestamp_checkbox.setChecked(self._current_include_timestamp) 223 | self.enable_multi_step_checkbox.setChecked(self._current_enable_multi_step) 224 | self.max_iterations_spinbox.setValue(self._current_multi_step_max_iterations) 225 | self.max_iterations_spinbox.setEnabled(self._current_enable_multi_step) # Ensure enabled state is correct 226 | # <<< MODIFICATION START: Update auto UI info checkbox >>> 227 | self.auto_include_ui_checkbox.setChecked(self._current_auto_include_ui_info) 228 | # Also re-check enabled state based on platform/library availability 229 | is_gui_available = platform.system() == "Windows" and getattr(config, 'UIAUTOMATION_AVAILABLE_FOR_GUI', False) 230 | self.auto_include_ui_checkbox.setEnabled(is_gui_available) 231 | # <<< MODIFICATION END >>> 232 | 233 | # Reset API Key visibility if key is now empty 234 | if not self._current_api_key: 235 | self.key_input.setEchoMode(QLineEdit.EchoMode.Password) 236 | self.show_hide_button.setChecked(False) 237 | self._update_visibility_icon(False) 238 | # No need to clear error label here, validation handles that 239 | 240 | def _update_visibility_icon(self, visible: bool): 241 | """Updates the icon for the show/hide API key button.""" 242 | icon_name_on = "visibility"; icon_name_off = "visibility_off" 243 | # Try to get themed icons 244 | icon = QIcon.fromTheme(icon_name_on if visible else icon_name_off) 245 | if icon.isNull(): 246 | # Fallback text/emoji if icons not found 247 | self.show_hide_button.setText("👁️" if visible else "🚫") 248 | self.show_hide_button.setIcon(QIcon()) # Clear icon if using text 249 | else: 250 | self.show_hide_button.setIcon(icon) 251 | self.show_hide_button.setText("") # Clear text if using icon 252 | 253 | def toggle_api_key_visibility(self, checked): 254 | """Slot to change API key echo mode and button icon.""" 255 | if checked: 256 | self.key_input.setEchoMode(QLineEdit.EchoMode.Normal) 257 | else: 258 | self.key_input.setEchoMode(QLineEdit.EchoMode.Password) 259 | self._update_visibility_icon(checked) 260 | 261 | def handle_reset_settings(self): 262 | """Handles the reset button click with confirmation.""" 263 | reply = QMessageBox.warning( 264 | self, "确认重置", 265 | "您确定要将所有设置恢复为默认值并清除所有缓存数据(包括API密钥、模型列表、聊天记录、命令历史、保存的目录和所有行为设置)吗?\n\n此操作无法撤销!", 266 | QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.Cancel, 267 | QMessageBox.StandardButton.Cancel # Default button is Cancel 268 | ) 269 | if reply == QMessageBox.StandardButton.Yes: 270 | print("User confirmed reset. Resetting settings and clearing cache...") 271 | try: 272 | # Call the config reset function 273 | config.reset_to_defaults_and_clear_cache() 274 | print("Config module reset executed.") 275 | # Update the dialog fields to reflect the reset defaults 276 | self.update_fields_from_config() 277 | print("Settings dialog fields updated to reflect reset.") 278 | # Hide any previous validation errors 279 | self.error_label.setVisible(False) 280 | # Inform the user 281 | QMessageBox.information(self, "重置完成", "设置已恢复为默认值,缓存已清除。\n您可能需要重新配置API密钥和模型ID才能使用AI功能。") 282 | except Exception as e: 283 | print(f"Error during reset process: {e}") 284 | QMessageBox.critical(self, "重置错误", f"恢复默认设置时发生错误:\n{e}") 285 | else: 286 | print("User cancelled reset.") 287 | 288 | 289 | def validate_and_accept(self): 290 | """Validate inputs before accepting the dialog.""" 291 | api_key = self.key_input.text().strip() 292 | api_url = self.url_input.text().strip() 293 | model_id_string = self.model_input.text().strip() 294 | 295 | # Clear previous errors 296 | self.error_label.setText(""); self.error_label.setVisible(False) 297 | # Reset stylesheets to default (important if previously marked red) 298 | default_style = ""; self.url_input.setStyleSheet(default_style); self.key_input.setStyleSheet(default_style); self.model_input.setStyleSheet(default_style) 299 | 300 | errors = [] 301 | # Validate API Key/URL/Model only if ANY of them are filled 302 | # If user intends to use API, all three are generally required. If all empty, it's fine. 303 | if api_key or api_url or model_id_string: 304 | if not api_url: errors.append("API URL") 305 | if not api_key: errors.append("API 密钥") 306 | if not model_id_string: errors.append("模型 ID") 307 | # Basic URL format check (very simple) 308 | if api_url and not (api_url.startswith("http://") or api_url.startswith("https://")): 309 | errors.append("API URL 格式无效 (应以 http:// 或 https:// 开头)") 310 | 311 | 312 | if errors: 313 | error_msg = "请修正以下错误:\n- " + "\n- ".join(errors); self.error_label.setText(error_msg); self.error_label.setVisible(True) 314 | # Highlight the fields with errors 315 | error_style = "border: 1px solid red;"; first_error_field = None 316 | if "API URL" in errors or "API URL 格式无效" in errors: self.url_input.setStyleSheet(error_style); first_error_field = first_error_field or self.url_input 317 | if "API 密钥" in errors: self.key_input.setStyleSheet(error_style); first_error_field = first_error_field or self.key_input 318 | if "模型 ID" in errors: self.model_input.setStyleSheet(error_style); first_error_field = first_error_field or self.model_input 319 | # Set focus to the first field with an error 320 | if first_error_field: first_error_field.setFocus() 321 | print(f"Settings validation failed: {error_msg}") 322 | return # Don't accept yet 323 | 324 | # Validation passed 325 | self.accept() 326 | 327 | def get_values(self): 328 | """Returns all configured values from the dialog widgets.""" 329 | selected_theme_data = self.theme_combobox.currentData() 330 | # Ensure theme data is one of the valid strings 331 | valid_themes = ["dark", "light", "system"] 332 | selected_theme = selected_theme_data if selected_theme_data in valid_themes else config.DEFAULT_APP_THEME 333 | 334 | # <<< MODIFICATION START: Return auto UI info checkbox state >>> 335 | return ( 336 | self.key_input.text().strip(), 337 | self.url_input.text().strip(), 338 | self.model_input.text().strip(), 339 | self.auto_startup_checkbox.isChecked(), 340 | selected_theme, 341 | self.include_cli_context_checkbox.isChecked(), 342 | self.include_timestamp_checkbox.isChecked(), 343 | self.enable_multi_step_checkbox.isChecked(), 344 | self.max_iterations_spinbox.value(), # Get value from spinbox 345 | self.auto_include_ui_checkbox.isChecked(), # Get state of the new checkbox 346 | ) 347 | # <<< MODIFICATION END >>> -------------------------------------------------------------------------------- /PowerAgent/gui/stylesheets.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/gui/stylesheets.py 3 | # (MODIFIED - Removed QPushButton:hover styling) 4 | # --------------------------------------- 5 | # gui/stylesheets.py 6 | # -*- coding: utf-8 -*- 7 | 8 | """ 9 | Contains the QSS stylesheet templates used by the MainWindow. 10 | """ 11 | 12 | # Stylesheet template (Removed QPushButton:hover styling) 13 | STYLESHEET_TEMPLATE = """ 14 | /* General */ 15 | QMainWindow {{ }} 16 | QWidget {{ 17 | color: {text_main}; 18 | }} 19 | QToolBar {{ 20 | border: none; 21 | padding: 2px; 22 | spacing: 5px; 23 | }} 24 | /* <<< REMOVED Toolbar CWD Label Styling >>> */ 25 | 26 | /* <<< ADDED: Styling for Toolbar ComboBox >>> */ 27 | QToolBar QComboBox#ModelSelectorCombo {{ 28 | /* font-size: 9pt; */ /* Uncomment to match label font size */ 29 | color: {status_label_color}; /* Match other status text color */ 30 | /* Add padding if needed */ 31 | /* padding: 1px 5px 1px 5px; */ 32 | min-width: 100px; /* Give it some minimum space */ 33 | /* background-color: {button_bg}; */ /* Optional: Match button background */ 34 | /* border: 1px solid {border}; */ /* Optional: Add border */ 35 | /* border-radius: 3px; */ /* Optional: Round corners */ 36 | }} 37 | QToolBar QComboBox#ModelSelectorCombo:disabled {{ 38 | color: {text_disabled}; 39 | }} 40 | /* <<< END ADDED >>> */ 41 | 42 | /* Separator Styling */ 43 | QToolBar QFrame {{ 44 | margin-left: 3px; 45 | margin-right: 3px; 46 | }} 47 | /* Settings Button Padding */ 48 | QToolBar QToolButton {{ 49 | padding-left: 3px; 50 | padding-right: 5px; 51 | padding-top: 2px; 52 | padding-bottom: 2px; 53 | }} 54 | /* Status indicator styling is done directly in the code */ 55 | 56 | QStatusBar {{ 57 | border-top: 1px solid {border}; 58 | }} 59 | 60 | /* CLI Area Specifics */ 61 | #CliOutput {{ 62 | background-color: {cli_bg}; 63 | color: {cli_output}; /* Default text color for CLI output */ 64 | border: 1px solid {border}; 65 | padding: 3px; 66 | font-family: {mono_font_family}; 67 | font-size: {mono_font_size}pt; 68 | }} 69 | #CliInput {{ 70 | background-color: {cli_bg}; 71 | color: {cli_output}; /* Match output color */ 72 | border: none; /* Input field has no border itself */ 73 | padding: 4px; 74 | font-family: {mono_font_family}; 75 | font-size: {mono_font_size}pt; 76 | }} 77 | #CliInputContainer {{ 78 | border: 1px solid {border}; 79 | background-color: {cli_bg}; 80 | border-radius: 3px; 81 | /* Container provides the border for the input */ 82 | }} 83 | #CliInputContainer:focus-within {{ 84 | border: 1px solid {highlight_bg}; 85 | }} 86 | /* Styling for the Prompt Label inside the container */ 87 | #CliPromptLabel {{ 88 | color: {prompt_color}; /* Use the prompt color */ 89 | padding: 4px 0px 4px 5px; /* Top/Bottom/Left padding like input, NO right padding */ 90 | margin-right: 0px; /* No margin between label and input */ 91 | background-color: {cli_bg}; /* Match container background */ 92 | font-family: {mono_font_family}; 93 | font-size: {mono_font_size}pt; 94 | font-weight: bold; /* Make prompt stand out slightly */ 95 | }} 96 | 97 | /* Chat Area Specifics */ 98 | #ChatHistoryDisplay {{ 99 | border: 1px solid {border}; 100 | padding: 3px; 101 | }} 102 | #ChatInput {{ 103 | border: 1px solid {border}; 104 | padding: 4px; 105 | border-radius: 3px; 106 | }} 107 | #ChatInput:focus {{ 108 | border: 1px solid {highlight_bg}; 109 | }} 110 | 111 | /* Other Widgets */ 112 | QPushButton {{ 113 | padding: 5px 10px; 114 | border-radius: 3px; 115 | min-height: 26px; 116 | background-color: {button_bg}; 117 | color: {button_text}; 118 | border: 1px solid {border}; 119 | }} 120 | /* <<< REMOVED QPushButton:hover block >>> */ 121 | /* QPushButton:hover {{ ... }} */ 122 | 123 | QPushButton:pressed {{ 124 | background-color: {button_pressed_bg}; 125 | }} 126 | QPushButton:disabled {{ 127 | background-color: {button_disabled_bg}; 128 | color: {text_disabled}; 129 | border: 1px solid {border_disabled}; 130 | padding: 5px 10px; /* Keep padding consistent */ 131 | min-height: 26px; /* Keep min-height consistent */ 132 | }} 133 | QLabel#StatusLabel {{ 134 | color: {status_label_color}; 135 | font-size: {label_font_size}pt; 136 | margin-left: 5px; 137 | }} 138 | QSplitter::handle {{ 139 | background-color: transparent; 140 | border: none; 141 | }} 142 | QSplitter::handle:horizontal {{ 143 | width: 5px; 144 | margin: 0 1px; 145 | }} 146 | QSplitter::handle:vertical {{ 147 | height: 5px; 148 | margin: 1px 0; 149 | }} 150 | QSplitter::handle:pressed {{ 151 | background-color: {highlight_bg}; 152 | }} 153 | QToolTip {{ 154 | border: 1px solid {border}; 155 | padding: 3px; 156 | background-color: {tooltip_bg}; 157 | color: {tooltip_text}; 158 | }} 159 | /* Specific Button Styling (Placeholders) */ 160 | #ClearChatButton {{ }} 161 | /* <<< REMOVED #ClearChatButton:hover placeholder >>> */ 162 | /* <<< ADDED: Placeholder selectors for the new button >>> */ 163 | #ClearCliButton {{ }} 164 | /* <<< REMOVED #ClearCliButton:hover placeholder >>> */ 165 | #ClearCliButton:pressed {{ }} 166 | #ClearCliButton:disabled {{ }} 167 | /* <<< END ADDED >>> */ 168 | """ 169 | 170 | # Minimal stylesheet (Removed ToolbarCwdLabel font styling) 171 | # This template already doesn't have a QPushButton:hover rule, 172 | # so hover effects rely on the base Qt style (Fusion). 173 | # Removing hover completely from Fusion might require more complex styling or subclassing, 174 | # but removing it from the custom QSS is achieved by simply not defining it. 175 | MINIMAL_STYLESHEET_SYSTEM_THEME = """ 176 | #CliOutput, #CliInput, #CliPromptLabel {{ 177 | font-family: {mono_font_family}; 178 | font-size: {mono_font_size}pt; 179 | }} 180 | #CliInputContainer {{ 181 | border: 1px solid {border}; 182 | border-radius: 3px; 183 | }} 184 | #CliInput {{ 185 | border: none; 186 | padding: 4px; 187 | }} 188 | #CliOutput {{ 189 | border: 1px solid {border}; 190 | padding: 3px; 191 | }} 192 | #ChatHistoryDisplay {{ 193 | border: 1px solid {border}; 194 | padding: 3px; 195 | }} 196 | #ChatInput {{ 197 | border: 1px solid {border}; 198 | padding: 4px; 199 | border-radius: 3px; 200 | }} 201 | QSplitter::handle {{ }} 202 | QToolTip {{ 203 | border: 1px solid {border}; 204 | padding: 3px; 205 | }} 206 | /* <<< REMOVED Toolbar CWD Label Styling >>> */ 207 | 208 | /* <<< ADDED: Basic Styling for Toolbar ComboBox in System Theme >>> */ 209 | QToolBar QComboBox#ModelSelectorCombo {{ 210 | /* font-size: 9pt; */ /* Uncomment to match label font size */ 211 | min-width: 100px; /* Give it some minimum space */ 212 | /* padding: 1px 5px 1px 5px; */ /* Optional padding */ 213 | }} 214 | /* <<< END ADDED >>> */ 215 | 216 | /* Separator Styling */ 217 | QToolBar QFrame {{ 218 | margin-left: 3px; 219 | margin-right: 3px; 220 | }} 221 | QToolBar QToolButton {{ 222 | padding-left: 3px; 223 | padding-right: 5px; 224 | padding-top: 2px; 225 | padding-bottom: 2px; 226 | }} 227 | QPushButton {{ 228 | padding: 5px 10px; 229 | min-height: 26px; 230 | /* No explicit hover rule here - uses style default */ 231 | }} 232 | QPushButton:disabled {{ 233 | padding: 5px 10px; 234 | min-height: 26px; 235 | }} 236 | """ -------------------------------------------------------------------------------- /PowerAgent/gui/ui_components.py: -------------------------------------------------------------------------------- 1 | # ======================================== 2 | # 文件名: PowerAgent/gui/ui_components.py 3 | # (MODIFIED - Implemented IME bypass via custom widgets) 4 | # WARNING: This approach is experimental and likely to break standard text editing features. 5 | # --------------------------------------- 6 | # gui/ui_components.py 7 | # -*- coding: utf-8 -*- 8 | 9 | """ 10 | Creates and lays out the UI elements for the MainWindow. 11 | Includes custom widgets to attempt bypassing IME for English input. 12 | """ 13 | from typing import TYPE_CHECKING # To avoid circular import for type hinting 14 | 15 | from PySide6.QtWidgets import ( 16 | QWidget, QVBoxLayout, QHBoxLayout, 17 | QTextEdit, QLineEdit, QPushButton, QSplitter, QLabel, QFrame, 18 | QSizePolicy, QToolBar, QCompleter, QComboBox 19 | ) 20 | from PySide6.QtCore import Qt, QSize, QStringListModel, QRect, Signal, QEvent 21 | from PySide6.QtGui import QAction, QIcon, QPainter, QColor, QBrush, QPen, QKeyEvent # Added QKeyEvent 22 | 23 | # Import config only if absolutely necessary 24 | from core import config 25 | 26 | # Type hinting for MainWindow without causing circular import at runtime 27 | if TYPE_CHECKING: 28 | from .main_window import MainWindow 29 | 30 | # ====================================================================== # 31 | # <<< Original Custom QTextEdit Subclass >>> 32 | # ====================================================================== # 33 | class ChatInputEdit(QTextEdit): 34 | """Custom QTextEdit that emits a signal on Enter press (without Shift).""" 35 | sendMessageRequested = Signal() 36 | 37 | def __init__(self, parent=None): 38 | super().__init__(parent) 39 | 40 | def keyPressEvent(self, event: QKeyEvent): # Changed type hint 41 | """Override keyPressEvent to handle Enter key.""" 42 | key = event.key() 43 | modifiers = event.modifiers() 44 | 45 | if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 46 | if not (modifiers & Qt.KeyboardModifier.ShiftModifier): 47 | self.sendMessageRequested.emit() # Emit signal on Enter 48 | event.accept() 49 | return 50 | else: 51 | # Allow Shift+Enter for new lines 52 | super().keyPressEvent(event) 53 | return 54 | super().keyPressEvent(event) 55 | # ====================================================================== # 56 | # <<< END >>> 57 | # ====================================================================== # 58 | 59 | 60 | # ====================================================================== # 61 | # <<< NEW: Custom QLineEdit to Bypass IME >>> 62 | # WARNING: Experimental and potentially buggy 63 | # ====================================================================== # 64 | class ImeBypassLineEdit(QLineEdit): 65 | """ 66 | Attempts to bypass IME for basic English input by intercepting keys 67 | and inserting characters programmatically. 68 | """ 69 | def __init__(self, parent=None): 70 | super().__init__(parent) 71 | 72 | def keyPressEvent(self, event: QKeyEvent): 73 | key = event.key() 74 | modifiers = event.modifiers() 75 | text = event.text() 76 | 77 | # --- Condition for Direct Insert --- 78 | # 1. Text is not empty and is a single character. 79 | # 2. The character is a basic printable ASCII character (Space to ~). 80 | # 3. No Ctrl, Alt, or Meta modifiers are pressed (allowing only Shift or none). 81 | is_direct_insert_candidate = ( 82 | text and len(text) == 1 and 83 | 32 <= ord(text[0]) <= 126 and 84 | not (modifiers & Qt.KeyboardModifier.ControlModifier) and 85 | not (modifiers & Qt.KeyboardModifier.AltModifier) and 86 | not (modifiers & Qt.KeyboardModifier.MetaModifier) 87 | ) 88 | 89 | if is_direct_insert_candidate: 90 | # print(f"ImeBypassLineEdit: Intercepted '{text}'") # Debug 91 | event.accept() # Consume the event, prevent IME/default handling 92 | self.insert(text) # Insert the character directly 93 | else: 94 | # print(f"ImeBypassLineEdit: Passing key {key} / text '{text}' to super") # Debug 95 | # Let the base class handle other keys (Enter, Backspace, Arrows, Ctrl+C, etc.) 96 | super().keyPressEvent(event) 97 | 98 | # ====================================================================== # 99 | # <<< NEW: Custom QTextEdit to Bypass IME >>> 100 | # WARNING: Experimental and potentially buggy 101 | # Inherits from ChatInputEdit to keep Enter key functionality 102 | # ====================================================================== # 103 | class ImeBypassTextEdit(ChatInputEdit): 104 | """ 105 | Attempts to bypass IME for basic English input by intercepting keys 106 | and inserting characters programmatically. Inherits ChatInputEdit features. 107 | """ 108 | def __init__(self, parent=None): 109 | super().__init__(parent) 110 | 111 | def keyPressEvent(self, event: QKeyEvent): 112 | key = event.key() 113 | modifiers = event.modifiers() 114 | text = event.text() 115 | 116 | # --- Check for Enter/Shift+Enter first (from base class logic) --- 117 | if key in (Qt.Key.Key_Return, Qt.Key.Key_Enter): 118 | if not (modifiers & Qt.KeyboardModifier.ShiftModifier): 119 | self.sendMessageRequested.emit() # Emit signal on Enter 120 | event.accept() 121 | return 122 | else: 123 | # Allow Shift+Enter for new lines - pass to base QTextEdit behavior 124 | # Need to call the grandparent's method directly if ChatInputEdit 125 | # doesn't handle Shift+Enter itself in its super call. Let's assume 126 | # QTextEdit's default handles Shift+Enter correctly. 127 | QTextEdit.keyPressEvent(self, event) # Call QTextEdit's implementation 128 | return 129 | 130 | # --- Condition for Direct Insert (same as LineEdit) --- 131 | is_direct_insert_candidate = ( 132 | text and len(text) == 1 and 133 | 32 <= ord(text[0]) <= 126 and 134 | not (modifiers & Qt.KeyboardModifier.ControlModifier) and 135 | not (modifiers & Qt.KeyboardModifier.AltModifier) and 136 | not (modifiers & Qt.KeyboardModifier.MetaModifier) 137 | ) 138 | 139 | if is_direct_insert_candidate: 140 | # print(f"ImeBypassTextEdit: Intercepted '{text}'") # Debug 141 | event.accept() # Consume the event 142 | cursor = self.textCursor() 143 | cursor.insertText(text) # Insert the character directly 144 | self.ensureCursorVisible() 145 | else: 146 | # print(f"ImeBypassTextEdit: Passing key {key} / text '{text}' to super") # Debug 147 | # Let the base class (ChatInputEdit -> QTextEdit) handle others 148 | super().keyPressEvent(event) 149 | 150 | # ====================================================================== # 151 | # <<< StatusIndicatorWidget (No changes from original) >>> 152 | # ====================================================================== # 153 | class StatusIndicatorWidget(QWidget): 154 | """A custom widget that draws a circular status indicator.""" 155 | def __init__(self, parent=None): 156 | super().__init__(parent) 157 | self._busy = False 158 | self._color_idle = QColor("limegreen") 159 | self._color_busy = QColor("red") 160 | self.setFixedSize(16, 16) 161 | 162 | def setBusy(self, busy: bool): 163 | """Sets the busy state and triggers a repaint.""" 164 | if self._busy != busy: 165 | self._busy = busy 166 | self.update() 167 | 168 | def paintEvent(self, event): 169 | """Overrides the paint event to draw a circle.""" 170 | painter = QPainter(self) 171 | painter.setRenderHint(QPainter.RenderHint.Antialiasing) 172 | color = self._color_busy if self._busy else self._color_idle 173 | painter.setBrush(QBrush(color)) 174 | painter.setPen(Qt.PenStyle.NoPen) 175 | rect = QRect(0, 0, self.width(), self.height()) 176 | painter.drawEllipse(rect) 177 | 178 | def sizeHint(self): 179 | return QSize(16, 16) 180 | # ====================================================================== # 181 | # <<< END >>> 182 | # ====================================================================== # 183 | 184 | 185 | def create_ui_elements(main_window: 'MainWindow'): 186 | """Creates and arranges all UI widgets for the given MainWindow instance.""" 187 | 188 | central_widget = QWidget() 189 | main_window.setCentralWidget(central_widget) 190 | main_layout = QVBoxLayout(central_widget) 191 | main_layout.setContentsMargins(5, 5, 5, 5) 192 | main_layout.setSpacing(5) 193 | 194 | # --- Toolbar Setup (No changes here) --- 195 | toolbar = main_window.addToolBar("Main Toolbar") 196 | toolbar.setObjectName("MainToolBar") 197 | toolbar.setMovable(False) 198 | toolbar.setIconSize(QSize(16, 16)) 199 | toolbar.setToolButtonStyle(Qt.ToolButtonStyle.ToolButtonTextBesideIcon) 200 | 201 | settings_icon = main_window._get_icon("preferences-system", "settings.png", "⚙️") 202 | settings_action = QAction(settings_icon, "设置", main_window) 203 | settings_action.triggered.connect(main_window.open_settings_dialog) 204 | toolbar.addAction(settings_action) 205 | 206 | spacer = QWidget() 207 | spacer.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred) 208 | toolbar.addWidget(spacer) 209 | 210 | main_window.model_selector_combo = QComboBox() 211 | main_window.model_selector_combo.setObjectName("ModelSelectorCombo") 212 | main_window.model_selector_combo.setSizePolicy(QSizePolicy.Policy.MinimumExpanding, QSizePolicy.Policy.Preferred) 213 | main_window.model_selector_combo.setMaximumWidth(180) 214 | toolbar.addWidget(main_window.model_selector_combo) 215 | 216 | status_separator = QFrame() 217 | status_separator.setFrameShape(QFrame.Shape.VLine) 218 | status_separator.setFrameShadow(QFrame.Shadow.Sunken) 219 | toolbar.addWidget(status_separator) 220 | 221 | left_indicator_spacer = QWidget(); left_indicator_spacer.setFixedWidth(5) 222 | toolbar.addWidget(left_indicator_spacer) 223 | main_window.status_indicator = StatusIndicatorWidget() 224 | toolbar.addWidget(main_window.status_indicator) 225 | right_indicator_spacer = QWidget(); right_indicator_spacer.setFixedWidth(5) 226 | toolbar.addWidget(right_indicator_spacer) 227 | 228 | # --- Splitter Setup (No changes here) --- 229 | main_window.splitter = QSplitter(Qt.Orientation.Horizontal) 230 | main_window.splitter.setObjectName("MainSplitter") 231 | main_layout.addWidget(main_window.splitter, 1) 232 | 233 | # --- Left Pane (CLI) --- 234 | left_widget = QWidget() 235 | left_layout = QVBoxLayout(left_widget) 236 | left_layout.setContentsMargins(0, 0, 5, 0) 237 | left_layout.setSpacing(3) 238 | 239 | main_window.cli_output_display = QTextEdit() 240 | main_window.cli_output_display.setObjectName("CliOutput") 241 | main_window.cli_output_display.setReadOnly(True) 242 | main_window.cli_output_display.setLineWrapMode(QTextEdit.LineWrapMode.NoWrap) 243 | 244 | cli_input_container = QWidget() 245 | cli_input_container.setObjectName("CliInputContainer") 246 | cli_input_layout = QHBoxLayout(cli_input_container) 247 | cli_input_layout.setContentsMargins(0, 0, 0, 0) 248 | cli_input_layout.setSpacing(0) 249 | 250 | main_window.cli_prompt_label = QLabel("PS>") 251 | main_window.cli_prompt_label.setObjectName("CliPromptLabel") 252 | 253 | # <<< MODIFICATION: Use ImeBypassLineEdit >>> 254 | main_window.cli_input = ImeBypassLineEdit() 255 | main_window.cli_input.setObjectName("CliInput") 256 | main_window.cli_input.setPlaceholderText("输入 Shell 命令 (↑/↓ 历史)...") 257 | # Remove ImhPreferLatin hint, keep NoPredictiveText 258 | main_window.cli_input.setInputMethodHints(Qt.InputMethodHint.ImhNoPredictiveText) 259 | # Connect returnPressed signal as before 260 | main_window.cli_input.returnPressed.connect(main_window.handle_manual_command) 261 | # <<< END MODIFICATION >>> 262 | 263 | cli_input_layout.addWidget(main_window.cli_prompt_label) 264 | cli_input_layout.addWidget(main_window.cli_input, 1) 265 | 266 | left_layout.addWidget(main_window.cli_output_display, 1) 267 | left_layout.addWidget(cli_input_container) 268 | main_window.splitter.addWidget(left_widget) 269 | 270 | # --- Right Pane (Chat) --- 271 | right_widget = QWidget() 272 | right_layout = QVBoxLayout(right_widget) 273 | right_layout.setContentsMargins(5, 0, 0, 0) 274 | right_layout.setSpacing(3) 275 | 276 | main_window.chat_history_display = QTextEdit() 277 | main_window.chat_history_display.setObjectName("ChatHistoryDisplay") 278 | main_window.chat_history_display.setReadOnly(True) 279 | main_window.chat_history_display.setLineWrapMode(QTextEdit.LineWrapMode.WidgetWidth) 280 | 281 | # <<< MODIFICATION: Use ImeBypassTextEdit >>> 282 | main_window.chat_input = ImeBypassTextEdit() 283 | main_window.chat_input.setObjectName("ChatInput") 284 | main_window.chat_input.setPlaceholderText("询问 AI 或输入 /help... (Shift+Enter 换行)") 285 | main_window.chat_input.setMaximumHeight(80) 286 | main_window.chat_input.setAcceptRichText(False) 287 | # Remove ImhPreferLatin hint, keep NoPredictiveText 288 | main_window.chat_input.setInputMethodHints(Qt.InputMethodHint.ImhNoPredictiveText) 289 | # <<< END MODIFICATION >>> 290 | 291 | # --- Button Layout (No changes here) --- 292 | button_layout = QHBoxLayout() 293 | button_layout.setContentsMargins(0, 0, 0, 0) 294 | button_layout.setSpacing(5) 295 | 296 | main_window.send_button = QPushButton("发送") 297 | main_window.send_button.setObjectName("SendStopButton") 298 | main_window.send_button.setIconSize(QSize(16, 16)) 299 | 300 | send_icon = main_window._get_icon("mail-send", "send.png", "▶️") 301 | main_window.send_button.setIcon(send_icon if not send_icon.isNull() else QIcon()) 302 | main_window.send_button.setToolTip("向 AI 发送消息 (Shift+Enter 换行)") 303 | main_window.send_button.clicked.connect(main_window.handle_send_stop_button_click) 304 | 305 | api_configured = bool(config.API_KEY and config.API_URL and config.MODEL_ID_STRING) 306 | main_window.send_button.setEnabled(api_configured) 307 | 308 | button_layout.addWidget(main_window.send_button) 309 | 310 | main_window.clear_chat_button = QPushButton("清除聊天") 311 | main_window.clear_chat_button.setObjectName("ClearChatButton") 312 | main_window.clear_chat_button.setIconSize(QSize(16, 16)) 313 | clear_icon = main_window._get_icon("edit-clear", "clear.png", None) 314 | clear_icon = clear_icon if not clear_icon.isNull() else main_window._get_icon("user-trash", "trash.png", "🗑️") 315 | main_window.clear_chat_button.setIcon(clear_icon if not clear_icon.isNull() else QIcon()) 316 | main_window.clear_chat_button.clicked.connect(main_window.handle_clear_chat) 317 | button_layout.addWidget(main_window.clear_chat_button) 318 | 319 | main_window.clear_cli_button = QPushButton("清空CLI") 320 | main_window.clear_cli_button.setObjectName("ClearCliButton") 321 | main_window.clear_cli_button.setIconSize(QSize(16, 16)) 322 | clear_cli_icon = main_window._get_icon("edit-clear-history", "clear_cli.png", None) 323 | if clear_cli_icon.isNull(): 324 | clear_cli_icon = main_window._get_icon("edit-clear", "clear.png", None) 325 | main_window.clear_cli_button.setIcon(clear_cli_icon if not clear_cli_icon.isNull() else QIcon()) 326 | main_window.clear_cli_button.clicked.connect(main_window.handle_clear_cli) 327 | button_layout.addWidget(main_window.clear_cli_button) 328 | 329 | right_layout.addWidget(main_window.chat_history_display, 1) 330 | right_layout.addWidget(main_window.chat_input) 331 | right_layout.addLayout(button_layout) 332 | main_window.splitter.addWidget(right_widget) 333 | 334 | # --- Connect Signals AFTER UI elements are created --- 335 | if main_window.model_selector_combo: 336 | main_window.model_selector_combo.currentTextChanged.connect(main_window.handle_model_selection_changed) 337 | if main_window.chat_input: 338 | # Connect Enter press signal (which is defined in ChatInputEdit / ImeBypassTextEdit) 339 | main_window.chat_input.sendMessageRequested.connect(main_window.handle_send_stop_button_click) 340 | 341 | # --- Status Bar --- 342 | main_window.status_bar = main_window.statusBar() 343 | main_window.status_bar.hide() -------------------------------------------------------------------------------- /PowerAgent/main.py: -------------------------------------------------------------------------------- 1 | # main.py 2 | # -*- coding: utf-8 -*- 3 | 4 | import sys 5 | import os 6 | import platform 7 | import ctypes 8 | import logging # Import standard logging 9 | import datetime # For fallback logger timestamp 10 | import threading # For fallback logger thread name 11 | import traceback # For fallback exception hook 12 | 13 | # --- Define Fallback Logger and Hook FIRST --- 14 | # Define these before the main try block so they are always available 15 | # if initial logging setup fails in any way. 16 | class FallbackLogger: 17 | def _log(self, level, msg, *args, exc_info=None, **kwargs): 18 | # Basic formatting, similar to the target format 19 | timestamp = datetime.datetime.now().strftime('%Y-%m-%d %H:%M:%S') 20 | thread_name = threading.current_thread().name 21 | log_line = f"{timestamp} - {level:<8} - FallbackLogger - {thread_name:<10} - {msg % args if args else msg}" 22 | print(log_line, file=sys.stderr) 23 | if exc_info: 24 | # Manually print traceback if requested 25 | effective_exc_info = sys.exc_info() if isinstance(exc_info, bool) else exc_info 26 | if effective_exc_info[0]: # Check if there's an actual exception 27 | traceback_lines = traceback.format_exception(*effective_exc_info) 28 | for line in traceback_lines: 29 | print(line.strip(), file=sys.stderr) 30 | 31 | def debug(self, msg, *args, **kwargs): self._log("DEBUG", msg, *args, **kwargs) 32 | def info(self, msg, *args, **kwargs): self._log("INFO", msg, *args, **kwargs) 33 | def warning(self, msg, *args, **kwargs): self._log("WARNING", msg, *args, **kwargs) 34 | def error(self, msg, *args, **kwargs): self._log("ERROR", msg, *args, **kwargs) 35 | def critical(self, msg, *args, **kwargs): self._log("CRITICAL", msg, *args, **kwargs) 36 | def exception(self, msg, *args, **kwargs): self._log("EXCEPTION", msg, *args, exc_info=True, **kwargs) 37 | 38 | # Define the fallback exception hook function using the FallbackLogger 39 | def fallback_excepthook(exc_type, exc_value, exc_traceback): 40 | # Instantiate a fallback logger *specifically for the hook* 41 | # This avoids potential issues if the global 'logger' variable isn't assigned yet 42 | hook_logger = FallbackLogger() 43 | hook_logger.critical("Unhandled exception caught (Fallback Handler):", exc_info=(exc_type, exc_value, exc_traceback)) 44 | 45 | # Initialize logger variable, will be assigned later 46 | logger = None 47 | 48 | # --- Early Logging Setup --- 49 | # Attempt to set up logging as the very first operational step 50 | try: 51 | # Ensure 'core' directory is correctly located relative to 'main.py' 52 | from core.logging_config import setup_logging, handle_exception 53 | 54 | # Configure logging 55 | setup_logging() 56 | 57 | # Set the global exception handler *after* logging is configured 58 | sys.excepthook = handle_exception 59 | 60 | # Get the logger for the main module *after* calling setup_logging 61 | logger = logging.getLogger(__name__) 62 | logger.info("Logging initialized successfully using core.logging_config.") 63 | 64 | except ImportError as log_imp_err: 65 | # Fallback if logging_config cannot be imported 66 | print(f"CRITICAL: Failed to import logging configuration: {log_imp_err}", file=sys.stderr) 67 | print("CRITICAL: Logging will fallback to basic print statements.", file=sys.stderr) 68 | # Assign the fallback logger and hook 69 | logger = FallbackLogger() 70 | sys.excepthook = fallback_excepthook 71 | logger.critical(f"Using FallbackLogger due to ImportError: {log_imp_err}") 72 | 73 | except Exception as log_setup_err: 74 | # Fallback if setup_logging itself fails 75 | print(f"CRITICAL: Failed during logging setup process: {log_setup_err}", file=sys.stderr) 76 | print("CRITICAL: Logging setup failed. Check core/logging_config.py and file permissions.", file=sys.stderr) 77 | # Assign the fallback logger and hook (FallbackLogger class is defined above now) 78 | logger = FallbackLogger() 79 | sys.excepthook = fallback_excepthook 80 | logger.critical(f"Using FallbackLogger due to setup Exception: {log_setup_err}. Check permissions/config.") 81 | 82 | 83 | # --- Continue with other imports AFTER attempting logging setup --- 84 | # Use the assigned logger (either real or fallback) from here on. 85 | # These imports should happen regardless of logging success/failure, 86 | # but failures here will now be logged by the assigned excepthook. 87 | try: 88 | from PySide6.QtWidgets import QApplication 89 | from PySide6.QtCore import QCoreApplication, QSettings # QSettings might be used later 90 | from PySide6.QtGui import QFont 91 | from constants import APP_NAME, ORG_NAME, SETTINGS_APP_NAME, get_color 92 | from gui.main_window import MainWindow 93 | from gui.palette import setup_palette 94 | from core import config # Config module might also need logging later 95 | except ImportError as e: 96 | # Use the logger (either real or fallback) to log this critical error 97 | logger.critical(f"Failed to import essential application modules: {e}. Please check installation and paths.", exc_info=True) 98 | sys.exit(1) # Exit if essential modules cannot be imported 99 | 100 | 101 | # --- Determine Application Base Directory --- 102 | # Using logger here ensures we log the determined path or errors 103 | application_base_dir = None 104 | try: 105 | if getattr(sys, 'frozen', False): 106 | # Running as a bundled executable (e.g., PyInstaller) 107 | application_base_dir = os.path.dirname(sys.executable) 108 | logger.info(f"Application is frozen (executable). Base directory: {application_base_dir}") 109 | else: 110 | # Running as a Python script 111 | # sys.argv[0] is the path of the script invoked 112 | main_script_path = os.path.abspath(sys.argv[0]) 113 | application_base_dir = os.path.dirname(main_script_path) 114 | logger.info(f"Application running as script. Main script: {main_script_path}. Base directory: {application_base_dir}") 115 | except Exception as path_err: 116 | logger.error(f"Could not reliably determine application base directory: {path_err}", exc_info=True) 117 | # Fallback to directory containing this main.py file 118 | application_base_dir = os.path.dirname(os.path.abspath(__file__)) 119 | logger.warning(f"Falling back to application base directory: {application_base_dir}") 120 | 121 | # Log the final determined path (already logged above based on condition) 122 | 123 | 124 | # --- Main Execution Block --- 125 | if __name__ == "__main__": 126 | 127 | # Log start message 128 | logger.info(f"--- Starting {APP_NAME} ---") 129 | logger.info(f"PID: {os.getpid()}") 130 | logger.info(f"Operating System: {platform.system()} {platform.release()} ({platform.version()}) Machine: {platform.machine()}") 131 | logger.info(f"Python Version: {sys.version.replace(os.linesep, ' ')}") # Avoid newlines in log 132 | try: 133 | # Import PySide6 dynamically to log version safely 134 | import PySide6 135 | qt_version = getattr(PySide6.QtCore, 'qVersion', lambda: "N/A")() 136 | pyside_version = getattr(PySide6, '__version__', 'N/A') 137 | logger.info(f"PySide6 Version: {pyside_version}, Qt Version: {qt_version}") 138 | except ImportError: 139 | logger.error("PySide6 module not found!") 140 | except Exception as qt_ver_err: 141 | logger.warning(f"Could not determine PySide6/Qt versions: {qt_ver_err}") 142 | 143 | 144 | # --- Platform Specific Setup --- 145 | if platform.system() == "Windows": 146 | myappid = f"{ORG_NAME}.{APP_NAME}.{SETTINGS_APP_NAME}.1.0" # Keep consistent AppUserModelID 147 | try: 148 | # ctypes should have been imported earlier 149 | ctypes.windll.shell32.SetCurrentProcessExplicitAppUserModelID(myappid) 150 | logger.info(f"Windows AppUserModelID set to: {myappid}") 151 | except AttributeError: 152 | logger.warning("Could not set AppUserModelID (ctypes or shell32 method might be missing/different).") 153 | except NameError: 154 | logger.warning("Could not set AppUserModelID (ctypes was not imported successfully).") 155 | except Exception as e: 156 | # Log the exception details but keep the level as warning 157 | logger.warning(f"Could not set AppUserModelID due to an error: {e}", exc_info=False) # Set exc_info=False for brevity 158 | 159 | # Set Organization and Application name EARLY for QSettings etc. 160 | try: 161 | QCoreApplication.setOrganizationName(ORG_NAME) 162 | QCoreApplication.setApplicationName(SETTINGS_APP_NAME) # Use the specific name for settings 163 | logger.info(f"Qt Organization Name: '{QCoreApplication.organizationName()}', Application Name: '{QCoreApplication.applicationName()}'") 164 | except Exception as qt_err: 165 | logger.error(f"Failed to set Qt Organization/Application Name: {qt_err}", exc_info=True) 166 | 167 | 168 | # --- Load Configuration EARLY --- 169 | # Configuration loading is critical, log steps and outcome 170 | logger.info("Loading application configuration...") 171 | try: 172 | # config.load_config() should ideally also contain logging statements 173 | load_success, load_message = config.load_config() 174 | log_level = logging.INFO if load_success else logging.WARNING 175 | logger.log(log_level, f"Configuration load status: Success={load_success}, Message='{load_message}'") 176 | # Log key config values at DEBUG level if needed, be careful with sensitive data 177 | # logger.debug(f"Loaded config - Theme: {config.APP_THEME}, AutoStart: {config.AUTO_STARTUP_ENABLED}") 178 | except Exception as config_load_err: 179 | logger.error(f"An unhandled error occurred during configuration loading: {config_load_err}", exc_info=True) 180 | # Depending on severity, you might want to exit or proceed with defaults 181 | logger.warning("Proceeding with potentially default configuration due to loading error.") 182 | 183 | 184 | # --- QApplication Setup --- 185 | logger.debug("Creating QApplication instance.") 186 | # It's good practice to wrap QApplication instantiation in a try-except block 187 | try: 188 | app = QApplication(sys.argv) 189 | except Exception as app_err: 190 | logger.critical(f"Failed to create QApplication instance: {app_err}", exc_info=True) 191 | sys.exit(1) # Cannot proceed without QApplication 192 | 193 | 194 | # --- Set Default Font --- 195 | try: 196 | default_font = QFont() 197 | default_font.setPointSize(10) # Or load from config if desired 198 | app.setFont(default_font) 199 | logger.info(f"Default application font set: Family='{default_font.family()}', Size={default_font.pointSize()}pt") 200 | except Exception as font_err: 201 | # Font setting is less critical, log as error but continue 202 | logger.error(f"Failed to set default application font: {font_err}", exc_info=True) 203 | 204 | 205 | # --- Apply Initial Theme/Style --- 206 | try: 207 | current_theme = getattr(config, 'APP_THEME', 'system') # Safely get theme from config 208 | logger.info(f"Applying initial application theme: '{current_theme}'") 209 | # setup_palette should ideally also contain logging 210 | setup_palette(app, current_theme) 211 | except Exception as theme_err: 212 | # Palette/Theme errors might impact usability, log as error 213 | logger.error(f"Failed to apply initial theme/palette ('{current_theme}'): {theme_err}", exc_info=True) 214 | 215 | 216 | # --- Create and Show Main Window --- 217 | main_window = None # Initialize before try block 218 | try: 219 | logger.info("Creating MainWindow instance...") 220 | # MainWindow initialization should also contain logging statements 221 | main_window = MainWindow(application_base_dir=application_base_dir) 222 | logger.info("Showing MainWindow...") 223 | main_window.show() 224 | logger.info("MainWindow shown successfully.") 225 | except Exception as mw_err: 226 | # Failure to create the main window is critical 227 | logger.critical(f"CRITICAL: Failed to create or show MainWindow: {mw_err}", exc_info=True) 228 | # Ensure QApplication exits cleanly if the window fails 229 | if app: 230 | app.quit() 231 | sys.exit(1) 232 | 233 | 234 | # --- Start Event Loop --- 235 | exit_code = -1 # Default error code if loop fails unexpectedly 236 | try: 237 | logger.info("Starting Qt application event loop...") 238 | exit_code = app.exec() 239 | # This log message executes after the loop finishes (window closed) 240 | logger.info(f"Qt application event loop finished. Exit code: {exit_code}") 241 | except Exception as loop_err: 242 | # Catching errors here is a last resort; ideally, errors are handled within the application 243 | logger.critical(f"CRITICAL: Unhandled exception during Qt application event loop: {loop_err}", exc_info=True) 244 | exit_code = 1 # Indicate an error exit 245 | finally: 246 | # This block executes regardless of whether the try block completed successfully or raised an exception 247 | logger.info(f"--- Exiting {APP_NAME} (Final Exit Code: {exit_code}) ---") 248 | # Ensure Python exits with the determined exit code 249 | sys.exit(exit_code) -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # PowerAgent 2 | 3 | 一个将 AI 聊天助手与本地命令行控制相结合的桌面应用程序。 4 | 5 | ![image](https://github.com/user-attachments/assets/451b6bde-ef95-4b63-b0ae-5c2127cf6f73) 6 | 7 | 8 | ## 主要功能 9 | 10 | * **AI 聊天界面**: 11 | * 与可配置的 AI 模型进行交互,获取帮助或执行任务。 12 | * 支持多模型选择。 13 | * 通过 `/help` 查看内置命令。 14 | * **集成命令行 (CLI)**: 15 | * 在应用程序内直接执行标准的 Shell 命令 (如 `ls`, `cd`, `python script.py`)。 16 | * 使用 `↑`/`↓` 浏览命令历史。 17 | * 使用 `Tab` 键在聊天输入和 CLI 输入之间快速切换焦点。 18 | * **AI 驱动的操作**: 19 | * AI 可以生成 `命令`,这些命令会自动在下方的 CLI 窗口回显并执行。 20 | * AI 可以生成 `键盘动作` (如模拟按键、热键、粘贴),这些动作会被自动执行。 21 | * **上下文感知**: 22 | * 应用程序在当前工作目录 (CWD) 中运行,支持 `cd` 命令更改目录。 23 | * (可选) 可配置将近期的 CLI 输出自动作为上下文发送给 AI。 24 | * **多步骤 AI (实验性)**: 25 | * (可选) 启用后,允许 AI 根据上一步操作的结果连续执行多个命令或键盘动作。 26 | * **个性化设置**: 27 | * 通过设置对话框 (`/settings` 或工具栏按钮) 配置 API 密钥/URL、模型列表。 28 | * 支持 **暗色**、**亮色** 和 **系统默认** 界面主题。 29 | * 配置是否开机自启动。 30 | * 配置是否在提示中包含时间戳。 31 | * **状态指示**: 32 | * 工具栏显示 AI 或 CLI 是否正在忙碌。 33 | 34 | ## 快速开始 35 | 36 | 1. **运行**: 启动应用程序 (`python main.py`)。 37 | 2. **配置**: 38 | * 首次运行时,点击工具栏左侧的 **设置** 按钮 (或在聊天框输入 `/settings`)。 39 | * 填入你的 AI 服务 API URL、API 密钥,以及想要使用的模型 ID (逗号分隔)。 40 | * 根据需要调整其他设置 (主题、自启动等)。 41 | * 点击“确定”保存。 42 | 3. **选择模型**: 在工具栏右侧的下拉框中选择一个已配置的模型。 43 | 4. **使用**: 44 | * **聊天框 (右上)**: 向 AI 提出你的需求,例如: 45 | * `列出当前目录的所有 python 文件` (可能会生成 `ls *.py`) 46 | * `创建一个名为 temp 的目录` (可能会生成 `mkdir temp`) 47 | * `模拟按下 CTRL+C` (可能会生成 ``) 48 | * AI 建议的 `` 或 `` 会在执行前在聊天和 CLI 窗口中提示。 49 | * **CLI 框 (左下)**: 直接输入并执行标准的 Shell 命令。按 `Enter` 执行。 50 | 51 | ## 功能演示 52 | 53 | ### AI 执行命令 54 | 55 | ![image](https://github.com/user-attachments/assets/2a2b1098-4da5-4cf8-b67a-4d1432815e75) 56 | 57 | 58 | ### AI 模拟键盘操作 59 | 60 | ![image](https://github.com/user-attachments/assets/fdd1ef2e-e2ef-49f8-820a-a3f97c50e3ab) 61 | 62 | 63 | ### 手动执行命令与 Tab 切换 64 | 65 | ![image](https://github.com/user-attachments/assets/857c00ce-b9a8-4a65-bf8d-2dcbf7502fda) 66 | 67 | 68 | --- 69 | 70 | --------------------------------------------------------------------------------