├── .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 | 
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 | 
56 |
57 |
58 | ### AI 模拟键盘操作
59 |
60 | 
61 |
62 |
63 | ### 手动执行命令与 Tab 切换
64 |
65 | 
66 |
67 |
68 | ---
69 |
70 |
--------------------------------------------------------------------------------