├── .gitignore ├── .vscode └── launch.json ├── LICENSE ├── README.md ├── config.json ├── gui ├── chat_box.py ├── prompt_box.py └── settings.py ├── html └── chat_template.html ├── icons ├── app_icon.png ├── background.png ├── browse.svg ├── camera.svg ├── clear.svg ├── close.svg ├── default.svg ├── drop_down_arrow.svg ├── edit.svg ├── go_previous.svg ├── left_arrow.svg ├── monitor.svg ├── refresh.svg ├── right_arrow.svg ├── send.svg ├── settings.svg ├── stop.svg ├── vision.svg ├── vision_disabled.svg ├── zoom_in.svg └── zoom_out.svg ├── main.py ├── requirements.txt ├── run.bat ├── run.sh ├── themes ├── dark.qss ├── ice.qss ├── light.qss └── synthwave.qss └── utils ├── chat_storage.py ├── ollama_utils.py ├── provider_utils.py ├── screenshot_utils.py └── settings_manager.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Byte-compiled / optimized / DLL files 2 | __pycache__/ 3 | *.py[cod] 4 | *$py.class 5 | 6 | # C extensions 7 | *.so 8 | 9 | # Distribution / packaging 10 | .Python 11 | build/ 12 | develop-eggs/ 13 | dist/ 14 | downloads/ 15 | eggs/ 16 | .eggs/ 17 | lib/ 18 | lib64/ 19 | parts/ 20 | sdist/ 21 | var/ 22 | wheels/ 23 | share/python-wheels/ 24 | *.egg-info/ 25 | .installed.cfg 26 | *.egg 27 | MANIFEST 28 | 29 | # PyInstaller 30 | # Usually these files are written by a python script from a template 31 | # before PyInstaller builds the exe, so as to inject date/other infos into it. 32 | *.manifest 33 | *.spec 34 | 35 | # Installer logs 36 | pip-log.txt 37 | pip-delete-this-directory.txt 38 | 39 | # Unit test / coverage reports 40 | htmlcov/ 41 | .tox/ 42 | .nox/ 43 | .coverage 44 | .coverage.* 45 | .cache 46 | nosetests.xml 47 | coverage.xml 48 | *.cover 49 | *.py,cover 50 | .hypothesis/ 51 | .pytest_cache/ 52 | cover/ 53 | 54 | # Translations 55 | *.mo 56 | *.pot 57 | 58 | # Django stuff: 59 | *.log 60 | local_settings.py 61 | db.sqlite3 62 | db.sqlite3-journal 63 | 64 | # Flask stuff: 65 | instance/ 66 | .webassets-cache 67 | 68 | # Scrapy stuff: 69 | .scrapy 70 | 71 | # Sphinx documentation 72 | docs/_build/ 73 | 74 | # PyBuilder 75 | .pybuilder/ 76 | target/ 77 | 78 | # Jupyter Notebook 79 | .ipynb_checkpoints 80 | 81 | # IPython 82 | profile_default/ 83 | ipython_config.py 84 | 85 | # pyenv 86 | # For a library or package, you might want to ignore these files since the code is 87 | # intended to run in multiple environments; otherwise, check them in: 88 | # .python-version 89 | 90 | # pipenv 91 | # According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. 92 | # However, in case of collaboration, if having platform-specific dependencies or dependencies 93 | # having no cross-platform support, pipenv may install dependencies that don't work, or not 94 | # install all needed dependencies. 95 | #Pipfile.lock 96 | 97 | # poetry 98 | # Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. 99 | # This is especially recommended for binary packages to ensure reproducibility, and is more 100 | # commonly ignored for libraries. 101 | # https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control 102 | #poetry.lock 103 | 104 | # pdm 105 | # Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. 106 | #pdm.lock 107 | # pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it 108 | # in version control. 109 | # https://pdm.fming.dev/latest/usage/project/#working-with-version-control 110 | .pdm.toml 111 | .pdm-python 112 | .pdm-build/ 113 | 114 | # PEP 582; used by e.g. github.com/David-OConnor/pyflow and github.com/pdm-project/pdm 115 | __pypackages__/ 116 | 117 | # Celery stuff 118 | celerybeat-schedule 119 | celerybeat.pid 120 | 121 | # SageMath parsed files 122 | *.sage.py 123 | 124 | # Environments 125 | .env 126 | .venv 127 | env/ 128 | venv/ 129 | ENV/ 130 | env.bak/ 131 | venv.bak/ 132 | 133 | # Spyder project settings 134 | .spyderproject 135 | .spyproject 136 | 137 | # Rope project settings 138 | .ropeproject 139 | 140 | # mkdocs documentation 141 | /site 142 | 143 | # mypy 144 | .mypy_cache/ 145 | .dmypy.json 146 | dmypy.json 147 | 148 | # Pyre type checker 149 | .pyre/ 150 | 151 | # pytype static type analyzer 152 | .pytype/ 153 | 154 | # Cython debug symbols 155 | cython_debug/ 156 | 157 | # PyCharm 158 | # JetBrains specific template is maintained in a separate JetBrains.gitignore that can 159 | # be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore 160 | # and can be added to the global gitignore or merged into this file. For a more nuclear 161 | # option (not recommended) you can uncomment the following to ignore the entire idea folder. 162 | #.idea/ 163 | 164 | __pycache__ 165 | config.json 166 | chat_history.json -------------------------------------------------------------------------------- /.vscode/launch.json: -------------------------------------------------------------------------------- 1 | { 2 | // Use IntelliSense to learn about possible attributes. 3 | // Hover to view descriptions of existing attributes. 4 | // For more information, visit: https://go.microsoft.com/fwlink/?linkid=830387 5 | "version": "0.2.0", 6 | "configurations": [ 7 | { 8 | "name": "Python Debugger: Current File", 9 | "type": "debugpy", 10 | "request": "launch", 11 | "program": "main.py", 12 | "console": "integratedTerminal" 13 | } 14 | ] 15 | } -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) Alfredo Fernandes 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | ![Captura de tela 2024-10-28 153215](https://github.com/user-attachments/assets/f65f7c1d-95ad-4f6c-a003-753c613515d7) 2 | 3 | # PixelLlama! 4 | 5 | A minimalist client solution for Ollama / OpenAI API written in python with ability to capture screen to use with vision models. 6 | - Screenshot capturing with multi-monitor support 7 | - Drag and Drop / Paste images to prompt 8 | - Markdown / Code blocks support 9 | - Easy tagging for models that are vision enabled 10 | - 3 display modes (Collapsed, Minimal and Expanded) 11 | - @ on start of prompt to easily choose the model 12 | - Chat history persistence 13 | - System prompt browser 14 | - Theme support 15 | - Compatible with Ollama / OpenAI API 16 | 17 | To-Do: 18 | - Refactor and removal of unecessary or redundant code (30-40% done) 19 | 20 | # Installation (Windows) 21 | git clone https://github.com/fredconex/PixelLlama.git 22 | cd ./PixelLlama 23 | ./run.bat 24 | 25 | Execution of run.bat will create a new environment, install the required packages automatically and launch PixelLlama. 26 | 27 | # Installation (Linux) 28 | In the terminal 29 | 30 | git clone https://github.com/fredconex/PixelLlama.git # clone repo 31 | cd PixelLlama 32 | python3 -m venv .venv #create virtual env 33 | source .venv/bin/activate # activate virtual env 34 | pip install -r requirements.txt #install dependencies 35 | python main.py #start script 36 | 37 | 38 | 39 | # Screenshots 40 | 41 | 42 | 43 | 44 | 45 | # Donate 46 | Donate with PayPal 47 | -------------------------------------------------------------------------------- /config.json: -------------------------------------------------------------------------------- 1 | { 2 | "theme": "dark", 3 | "provider": "openai", 4 | "openai_url": "http://localhost:1234/v1", 5 | "openai_key": "lm-studio", 6 | "openai_default_model": "minicpm-v-2_6", 7 | "ollama_url": "http://localhost:11434", 8 | "ollama_default_model": "minicpm-v:8b", 9 | "temperature": null, 10 | "context_size": null, 11 | "system_prompt": "You're a helpful assistant.", 12 | "vision_capable_models": [ 13 | "bakllava", 14 | "llama3.2-vision", 15 | "llava", 16 | "llava-llama3", 17 | "minicpm-v", 18 | "minicpm-v-2_6", 19 | "x/llama3.2-vision" 20 | ], 21 | "saved_prompts": [ 22 | "You're a helpful assistant.", 23 | "You're a skillfull coder, use modern practices and clean code." 24 | ] 25 | } -------------------------------------------------------------------------------- /gui/chat_box.py: -------------------------------------------------------------------------------- 1 | import uuid 2 | from PyQt6.QtWidgets import QWidget, QVBoxLayout 3 | from PyQt6.QtWebEngineWidgets import QWebEngineView 4 | from PyQt6.QtWebChannel import QWebChannel 5 | from PyQt6.QtCore import ( 6 | QObject, 7 | pyqtSlot, 8 | QByteArray, 9 | QBuffer, 10 | QIODevice, 11 | QThread, 12 | QTimer, 13 | ) 14 | from PyQt6.QtGui import QImage, QIcon 15 | import os 16 | import json 17 | import base64 18 | from pathlib import Path 19 | from datetime import datetime 20 | from gui.settings import get_base_model_name, load_svg_button_icon 21 | from utils.settings_manager import get_default_model 22 | from utils.chat_storage import ChatStorage 23 | from collections import OrderedDict 24 | from utils.provider_utils import ProviderRequest 25 | import re 26 | 27 | DEBUG = "-debug" in __import__("sys").argv 28 | 29 | 30 | class Message: 31 | def __init__(self, role, content=None, model=None, message_id=None): 32 | self.role = role 33 | self.content = content or [] 34 | self.model = model 35 | self.id = message_id or str(uuid.uuid4()) 36 | self.parent_chat = None # Reference to parent chat box 37 | self.timestamp = datetime.now() 38 | self.child_message = None # Reference to the assistant's response message 39 | self.is_editing = False 40 | self.original_content = None # Store original content during edits 41 | 42 | def submit(self): 43 | """Submit this message and generate a response.""" 44 | if not self.parent_chat: 45 | print("Warning: Message not associated with a ChatBox") 46 | return 47 | 48 | if DEBUG: 49 | print("\n=== Submitting message ===") 50 | print(f"Message ID: {self.id}") 51 | 52 | # Store original IDs before regenerating 53 | original_id = self.id 54 | original_child_id = self.child_message.id if self.child_message else None 55 | 56 | # Remove all messages after this one 57 | if self.role == "user": 58 | self.parent_chat.remove_subsequent_messages(original_id) 59 | 60 | # Ensure we keep the same ID when regenerating 61 | self.id = original_id 62 | self.parent_chat.messages[original_id] = self 63 | 64 | # Create or update child message for assistant response 65 | if not self.child_message and self.role == "user": 66 | self.child_message = Message( 67 | "assistant", model=self.parent_chat.active_model 68 | ) 69 | self.child_message.parent_chat = self.parent_chat 70 | elif original_child_id: # Preserve child message ID if it exists 71 | self.child_message.id = original_child_id 72 | 73 | self.parent_chat.messages[self.child_message.id] = self.child_message 74 | 75 | # Get messages up to this point 76 | messages_to_send = self.parent_chat.get_messages_for_request(self.id) 77 | 78 | # Start the provider request 79 | self.parent_chat.start_provider_request( 80 | messages_to_send, 81 | self.get_images(), 82 | message_id=self.child_message.id if self.child_message else None, 83 | ) 84 | 85 | def regenerate(self): 86 | """Regenerate this message's response.""" 87 | if self.role == "assistant": 88 | # Clear content and find parent to resubmit 89 | self.content = [] 90 | parent = self._find_parent_message() 91 | if parent: 92 | parent.submit() 93 | else: 94 | # For user messages, just resubmit 95 | self.submit() 96 | 97 | def _find_parent_message(self): 98 | """Find the parent message that generated this response.""" 99 | if self.role != "assistant": 100 | return None 101 | 102 | for msg in self.parent_chat.messages.values(): 103 | if msg.child_message and msg.child_message.id == self.id: 104 | return msg 105 | return None 106 | 107 | def get_content(self): 108 | """Get the content of the message""" 109 | return self.content 110 | 111 | def set_text(self, text): 112 | """Set the text content of the message""" 113 | self.content = [{"type": "text", "text": text}] 114 | 115 | def get_text(self): 116 | """Get the text content of the message""" 117 | return next( 118 | (item["text"] for item in self.content if item["type"] == "text"), "" 119 | ) 120 | 121 | def get_images(self): 122 | """Extract images from message content.""" 123 | screenshots = [] 124 | for item in self.content: 125 | if item.get("type") == "image" and "image_url" in item: 126 | try: 127 | img_url = item["image_url"]["url"] 128 | base64_data = img_url.split(",")[1] 129 | img_data = base64.b64decode(base64_data) 130 | qimage = QImage() 131 | qimage.loadFromData(img_data) 132 | screenshots.append(qimage) 133 | except Exception as e: 134 | print(f"Error extracting screenshot: {e}") 135 | return screenshots 136 | 137 | def handle_response_chunk(self, chunk): 138 | """Handle incoming response chunk for this message.""" 139 | if isinstance(chunk, str) and chunk.startswith("Error:"): 140 | self.content = [{"type": "text", "text": f"⚠️ {chunk}"}] 141 | self.model = self.parent_chat.active_model or get_default_model() 142 | return 143 | 144 | try: 145 | # If this is an existing message being edited, append the chunk 146 | if self.content and self.content[0]["type"] == "text": 147 | current_text = self.content[0]["text"] 148 | self.content = [{"type": "text", "text": current_text + chunk}] 149 | else: 150 | # For new messages, set the content directly 151 | self.content = [{"type": "text", "text": chunk}] 152 | 153 | self.model = self.parent_chat.active_model or get_default_model() 154 | 155 | # Update the chat display 156 | if self.parent_chat: 157 | self.parent_chat.rebuild_chat_content() 158 | self.parent_chat.update_chat_display() 159 | 160 | except Exception as e: 161 | print(f"Error in handle_response_chunk: {str(e)}") 162 | if DEBUG: 163 | import traceback 164 | 165 | traceback.print_exc() 166 | 167 | def to_dict(self): 168 | """Convert message to dictionary format.""" 169 | return { 170 | "role": self.role, 171 | "content": self.content, 172 | "model": self.model, 173 | "id": self.id, 174 | } 175 | 176 | @classmethod 177 | def from_dict(cls, data, parent_chat=None): 178 | """Create a Message instance from dictionary data.""" 179 | msg = cls( 180 | role=data.get("role"), 181 | content=data.get("content"), 182 | model=data.get("model"), 183 | message_id=data.get("id"), 184 | ) 185 | msg.parent_chat = parent_chat # Set the parent_chat reference 186 | return msg 187 | 188 | def start_edit(self): 189 | """Start editing this message.""" 190 | if self.role != "user": 191 | return False 192 | 193 | self.is_editing = True 194 | self.original_content = self.content.copy() # Backup content 195 | 196 | # Signal chat box to update UI 197 | if self.parent_chat: 198 | self.parent_chat.handle_edit_start(self) 199 | return True 200 | 201 | def cancel_edit(self): 202 | """Cancel editing and restore original content.""" 203 | if not self.is_editing: 204 | return 205 | 206 | self.content = self.original_content 207 | self.is_editing = False 208 | self.original_content = None 209 | 210 | if self.parent_chat: 211 | self.parent_chat.handle_edit_end(self) 212 | self.parent_chat.rebuild_chat_content() 213 | 214 | def prepare_content_with_images(self): 215 | """Prepare message content including properly formatted images.""" 216 | content = [] 217 | 218 | # Add text content if exists 219 | text = self.get_text() 220 | if text: 221 | content.append({"type": "text", "text": text}) 222 | 223 | # Add images if any 224 | for image in self.get_images(): 225 | byte_array = QByteArray() 226 | buffer = QBuffer(byte_array) 227 | buffer.open(QIODevice.OpenModeFlag.WriteOnly) 228 | image.save(buffer, "PNG") 229 | image_base64 = byte_array.toBase64().data().decode() 230 | content.append( 231 | { 232 | "type": "image", 233 | "image_url": {"url": f"data:image/png;base64,{image_base64}"}, 234 | } 235 | ) 236 | 237 | return content 238 | 239 | def submit_edit(self, new_content=None): 240 | """Submit edited content and regenerate response.""" 241 | if not self.is_editing: 242 | return 243 | 244 | if new_content is not None: 245 | if isinstance(new_content, str): 246 | self.set_text(new_content) 247 | else: 248 | self.content = new_content 249 | 250 | # Ensure images are properly formatted in content 251 | self.content = self.prepare_content_with_images() 252 | 253 | self.is_editing = False 254 | self.original_content = None 255 | 256 | if self.parent_chat: 257 | self.parent_chat.handle_edit_end(self) 258 | 259 | # Store the existing child message reference and ID 260 | existing_child = self.child_message 261 | child_id = existing_child.id if existing_child else None 262 | 263 | # Clear the child message's content but preserve its ID 264 | if existing_child: 265 | existing_child.content = [] 266 | self.child_message = existing_child # Ensure the reference is maintained 267 | 268 | # Submit to regenerate response 269 | self.submit() 270 | 271 | 272 | class Bridge(QObject): 273 | def __init__(self, parent_chat): 274 | super().__init__() 275 | self.parent_chat = parent_chat 276 | 277 | @pyqtSlot(str) 278 | def regenerateMessage(self, message_id): 279 | print("regenerateMessage", message_id) 280 | self.parent_chat.messages[message_id].regenerate() 281 | 282 | @pyqtSlot(str) 283 | def editMessage(self, message_id): 284 | if DEBUG: 285 | print("editMessage", message_id) 286 | self.parent_chat.messages[message_id].start_edit() 287 | 288 | 289 | class ProviderStatusThread(QThread): 290 | def __init__(self, settings_interface): 291 | super().__init__() 292 | self.settings_interface = settings_interface 293 | 294 | def run(self): 295 | self.settings_interface.reload_models() 296 | 297 | 298 | class ChatBox(QWidget): 299 | def __init__(self, parent=None, chat_instance=None): 300 | super().__init__(parent) 301 | self.parent = parent 302 | self.ICONS = Path(__file__).parent.parent / "icons" 303 | 304 | # Initialize state 305 | self.is_online_tracker = False 306 | self.chat_instance = chat_instance 307 | self.chat_content = [] 308 | self.current_response = "" 309 | self.is_receiving = False 310 | self.current_editing_message = None 311 | self.provider_status_displayed = False 312 | self.chat_storage = ChatStorage() 313 | self.active_model = None 314 | self.messages = OrderedDict() # Messages are stored in an ordered dictionary 315 | self.time_to_update_provider_status = 0 316 | self.initUI() 317 | 318 | # Load chat history into ordered dict 319 | history = self.chat_storage.load_chat_history() 320 | previous_user_msg = None 321 | for msg_data in history: 322 | msg = Message.from_dict(msg_data, self) 323 | self.messages[msg.id] = msg 324 | # Update active_model to the last assistant's model 325 | if msg.role == "assistant" and msg.model: 326 | self.active_model = msg.model 327 | # Link assistant message to previous user message 328 | if previous_user_msg: 329 | previous_user_msg.child_message = msg 330 | elif msg.role == "user": 331 | previous_user_msg = msg 332 | 333 | def initUI(self): 334 | layout = QVBoxLayout(self) 335 | layout.setContentsMargins(0, 0, 0, 0) 336 | 337 | # Chat display 338 | self.chat_display = QWebEngineView() 339 | 340 | layout.addWidget(self.chat_display, 1) 341 | self.initialize_chat_display() 342 | 343 | # Set up the bridge 344 | self.channel = QWebChannel() 345 | self.bridge = Bridge(self) 346 | self.channel.registerObject("bridge", self.bridge) 347 | self.chat_display.page().setWebChannel(self.channel) 348 | 349 | # Connect the JavaScript bridge 350 | self.chat_display.page().loadFinished.connect(self.onLoadFinished) 351 | 352 | # Check provider status when chat display loads and initialize timer 353 | self.chat_display.loadFinished.connect( 354 | lambda: self.chat_instance.settings_interface.reload_models(update_ui=True) 355 | ) 356 | self.chat_display.loadFinished.connect( 357 | lambda: ( 358 | setattr(self, "provider_status_timer", QTimer()), 359 | getattr(self, "provider_status_timer").timeout.connect( 360 | self.check_provider_status 361 | ), 362 | getattr(self, "provider_status_timer").start(1000), 363 | ) 364 | ) 365 | 366 | def initialize_chat_display(self): 367 | """Initializes the chat display with HTML template and app icon""" 368 | html_path = Path(__file__).parent.parent / "html/chat_template.html" 369 | with open(html_path, "r") as file: 370 | initial_html = file.read() 371 | 372 | # Read and encode the app icon 373 | icon_path = self.ICONS / "background.png" 374 | with open(icon_path, "rb") as icon_file: 375 | icon_data = icon_file.read() 376 | icon_base64 = base64.b64encode(icon_data).decode("utf-8") 377 | 378 | # Replace the placeholder with the base64-encoded image 379 | initial_html = initial_html.replace("{{APP_ICON_BASE64}}", icon_base64) 380 | 381 | self.chat_display.setHtml(initial_html) 382 | 383 | # Wait for page to load before updating colors 384 | self.chat_display.loadFinished.connect(lambda: self.update_webview_colors()) 385 | 386 | def extract_color(self, widget_selector, property_name, style=None): 387 | """Extract color value from QSS for given widget and property.""" 388 | if not style: 389 | return None 390 | 391 | try: 392 | match = re.search( 393 | f"{widget_selector}\\s*{{[^}}]*{property_name}:\\s*([^;}}\\s]+)", style 394 | ) 395 | return match.group(1) if match else None 396 | except Exception as e: 397 | print(f"Error extracting color: {e}") 398 | return None 399 | 400 | def update_webview_colors(self): 401 | """Updates all theme colors in the webview to match QSS""" 402 | main_widget = self.parent 403 | if not main_widget or not main_widget.styleSheet(): 404 | return 405 | 406 | style = main_widget.styleSheet() 407 | 408 | colors = { 409 | "backgroundColor": self.extract_color( 410 | "QWidget#mainWidget", "background-color", style 411 | ), 412 | "messageBackgroundColor": self.extract_color( 413 | "QWidget#chatMessage", "background-color", style 414 | ) 415 | or "#333333", 416 | "assistantBackgroundColor": self.extract_color( 417 | "QWidget#chatMessageAssistant", "background-color", style 418 | ) 419 | or "#222222", 420 | "messageFontColor": self.extract_color( 421 | "QWidget#chatMessage", "color", style 422 | ) 423 | or "#D4D4D4", 424 | "userBorderColor": self.extract_color( 425 | "QWidget#chatMessageUser", "border-color", style 426 | ) 427 | or "#7289DA", 428 | "assistantBorderColor": self.extract_color( 429 | "QWidget#chatMessageAssistant", "border-color", style 430 | ) 431 | or "#53629b", 432 | } 433 | 434 | # Convert the colors dict to a JavaScript object string 435 | js = f'if (typeof updateThemeColors === "function") {{ updateThemeColors({json.dumps(colors)}); }}' 436 | self.chat_display.page().runJavaScript(js) 437 | 438 | def remove_subsequent_messages(self, message_id): 439 | """Remove all messages that come after the specified message.""" 440 | if message_id not in self.messages: 441 | return 442 | 443 | # Convert to list for iteration since we'll modify the dict 444 | message_ids = list(self.messages.keys()) 445 | found_message = False 446 | 447 | for msg_id in message_ids: 448 | if found_message: 449 | del self.messages[msg_id] 450 | if msg_id == message_id: 451 | found_message = True 452 | 453 | def handle_response_chunk(self, chunk, message_id): 454 | """Route response chunks to appropriate message.""" 455 | if isinstance(chunk, str) and chunk.startswith("Error:"): 456 | # Set provider offline if request failed 457 | self.chat_instance.provider_online = False 458 | 459 | if DEBUG: 460 | print("\n=== handle_response_chunk ===") 461 | print(f"Incoming chunk for message ID: {message_id}") 462 | 463 | if message_id and message_id in self.messages: 464 | # Route to existing message 465 | self.messages[message_id].handle_response_chunk(chunk) 466 | else: 467 | # Create new message or update last message 468 | if not self.current_response: 469 | self.current_response = chunk 470 | new_msg = Message( 471 | "assistant", 472 | [{"type": "text", "text": chunk}], 473 | self.active_model or get_default_model(), 474 | ) 475 | new_msg.parent_chat = self 476 | self.messages[new_msg.id] = new_msg 477 | else: 478 | self.current_response = chunk 479 | last_msg = next(reversed(self.messages.values())) 480 | last_msg.handle_response_chunk(self.current_response) 481 | 482 | self.update_chat_display() 483 | 484 | def handle_response_complete(self): 485 | """Handle completion of Ollama response.""" 486 | if not self.current_response: 487 | self.current_response = "No response received from Assistant." 488 | 489 | # Get the current model being used 490 | current_model = self.active_model or get_default_model() 491 | 492 | # Get the last message 493 | last_msg = next(reversed(self.messages.values()), None) 494 | 495 | # Update model if it's an assistant message 496 | if last_msg and last_msg.role == "assistant": 497 | last_msg.model = current_model 498 | 499 | # Reset state 500 | self.current_response = "" 501 | self.is_receiving = False 502 | self.chat_instance.update_gradient_state() 503 | load_svg_button_icon(self.chat_instance.send_btn, self.ICONS / "send.svg") 504 | self.chat_instance.send_btn.setObjectName("sendButton") 505 | 506 | def rebuild_chat_content(self): 507 | """Reconstruct chat_content based on messages.""" 508 | DEBUG = False 509 | if DEBUG: 510 | print("\n=== rebuild_chat_content ===") 511 | print("Current messages:") 512 | for msg_id, msg in self.messages.items(): 513 | print(f"ID: {msg_id}") 514 | print(f"Role: {msg.role}") 515 | print(f"Content: {msg.get_text()[:100]}...") 516 | 517 | self.chat_content = [] 518 | 519 | for message in self.messages.values(): 520 | if message.role == "system": 521 | continue 522 | 523 | # Ensure parent_chat reference is set 524 | message.parent_chat = self 525 | 526 | try: 527 | sender = "user" if message.role == "user" else message.model 528 | text_content = message.get_text() 529 | images_html = "" 530 | 531 | # Process images if any 532 | for item in message.content: 533 | if item.get("type") == "image" and "image_url" in item: 534 | img_url = item["image_url"]["url"] 535 | images_html += f'Screenshot' 536 | 537 | self.chat_content.append((sender, text_content, images_html)) 538 | 539 | except Exception as e: 540 | if DEBUG: 541 | print(f"Error processing message: {str(e)}") 542 | print(f"Message content: {message}") 543 | 544 | if DEBUG: 545 | print("\nFinal chat_content length:", len(self.chat_content)) 546 | 547 | self.update_chat_display() 548 | 549 | def handle_edit_start(self, message): 550 | """Handle when a message starts being edited.""" 551 | self.current_editing_message = message 552 | 553 | # Update UI to show editing state 554 | self.chat_instance.send_btn.setIcon(QIcon("icons/edit.png")) 555 | load_svg_button_icon(self.chat_instance.send_btn, self.ICONS / "edit.svg") 556 | self.chat_instance.input_field.setText(message.get_text()) 557 | 558 | # Clear existing screenshots and add images from the message being edited 559 | self.chat_instance.prompt_images.clear() 560 | for item in message.content: 561 | if item.get("type") == "image" and "image_url" in item: 562 | try: 563 | img_url = item["image_url"]["url"] 564 | base64_data = img_url.split(",")[1] 565 | img_data = base64.b64decode(base64_data) 566 | qimage = QImage() 567 | qimage.loadFromData(img_data) 568 | self.chat_instance.prompt_images.append(qimage) 569 | except Exception as e: 570 | print(f"Error loading image during edit: {e}") 571 | 572 | self.chat_instance.update_thumbnails() 573 | 574 | def handle_edit_end(self, message): 575 | """Handle when message editing ends.""" 576 | self.current_editing_message = None 577 | self.chat_instance.input_field.clear() 578 | self.chat_instance.prompt_images.clear() 579 | self.chat_instance.update_thumbnails() 580 | load_svg_button_icon(self.chat_instance.send_btn, self.ICONS / "send.svg") 581 | 582 | def handle_message_action(self, message_id, action): 583 | """Handle message actions (edit, regenerate, etc.).""" 584 | if message_id not in self.messages: 585 | return 586 | 587 | message = self.messages[message_id] 588 | 589 | if action == "edit": 590 | message.start_edit() 591 | elif action == "regenerate": 592 | message.regenerate() 593 | elif action == "cancel_edit": 594 | message.cancel_edit() 595 | 596 | def send_message(self, content, model=None): 597 | """Handle sending a new message or submitting an edit.""" 598 | if self.current_editing_message: 599 | self.current_editing_message.submit_edit(content) 600 | return 601 | 602 | # Update active model if one is specified 603 | if model: 604 | self.active_model = model 605 | 606 | # Create new message with the specified model 607 | new_message = Message("user", content, self.active_model) 608 | new_message.parent_chat = self 609 | self.messages[new_message.id] = new_message 610 | 611 | # Update display and submit 612 | self.rebuild_chat_content() 613 | self.save_chat_history() 614 | new_message.submit() 615 | 616 | def clear_chat(self): 617 | self.chat_content.clear() 618 | self.messages.clear() 619 | self.current_editing_message = None 620 | self.update_chat_display() 621 | self.chat_instance.input_field.clear() 622 | self.selected_screenshot = None 623 | self.chat_instance.screenshot_btn.setStyleSheet( 624 | self.chat_instance.original_button_style 625 | ) 626 | self.active_model = None 627 | # Save empty chat history 628 | self.save_chat_history() 629 | 630 | def handle_js_console(self, level, message, line, source): 631 | """Handle JavaScript console messages.""" 632 | if DEBUG: 633 | print(f"JS Console ({level}): {message} [line {line}] {source}") 634 | 635 | def check_provider_status(self): 636 | """Check provider status and update UI accordingly.""" 637 | self.time_to_update_provider_status += 1 638 | if self.time_to_update_provider_status > ( 639 | 20 if self.chat_instance.provider_online else 5 640 | ): 641 | self.time_to_update_provider_status = 0 642 | # if self.chat_instance.status_thread is None: 643 | self.chat_instance.status_thread = ProviderStatusThread( 644 | self.chat_instance.settings_interface 645 | ) 646 | # self.chat_instance.status_thread.finished_cleanup.connect(self.cleanup_reload_thread) 647 | self.chat_instance.status_thread.start() 648 | self.handle_provider_status() 649 | 650 | def cleanup_reload_thread(self, thread): 651 | """Clean up the reload thread reference""" 652 | if hasattr(self, "reload_thread") and self.reload_thread == thread: 653 | self.reload_thread = None 654 | 655 | def handle_provider_status(self): 656 | """Handle the provider status results from the thread.""" 657 | # Update timer interval based on status 658 | provider_online = self.chat_instance.provider_online 659 | 660 | if self.is_online_tracker != provider_online: # Only update if state changed 661 | self.chat_instance.update_gradient_state() 662 | self.chat_instance.update_gradient() # Force immediate gradient update 663 | 664 | # If we just came online and have messages, display them 665 | if provider_online and len(self.messages) > 0: 666 | self.rebuild_chat_content() 667 | 668 | # Update local state 669 | self.is_online_tracker = provider_online 670 | 671 | # Update status in UI 672 | status_message = {"provider": "Unknown", "online": provider_online} 673 | 674 | self.chat_display.page().runJavaScript( 675 | f"updateProviderStatus({json.dumps(status_message)})" 676 | ) 677 | self.chat_instance.send_btn.setEnabled(self.chat_instance.provider_online) 678 | 679 | def onLoadFinished(self, ok): 680 | if ok: 681 | js = """ 682 | new QWebChannel(qt.webChannelTransport, function(channel) { 683 | window.bridge = channel.objects.bridge; 684 | window.qt_bridge = { 685 | regenerateMessage: function(messageId) { 686 | if (window.bridge) { 687 | window.bridge.regenerateMessage(messageId); 688 | } else { 689 | console.error('Bridge not initialized'); 690 | } 691 | }, 692 | editMessage: function(messageId) { 693 | if (window.bridge) { 694 | window.bridge.editMessage(messageId); 695 | } else { 696 | console.error('Bridge not initialized'); 697 | } 698 | } 699 | }; 700 | }); 701 | """ 702 | self.chat_display.page().runJavaScript(js) 703 | 704 | def regenerate_message(self, message_id): 705 | """Regenerate a specific message in the chat history.""" 706 | if message_id in self.messages: 707 | message = self.messages[message_id] 708 | message.parent_chat = self # Ensure the message is linked to this chat box 709 | message.regenerate() 710 | else: 711 | print(f"Warning: Message {message_id} not found in chat history") 712 | 713 | def edit_message(self, message_id): 714 | """Start editing a message with the given ID.""" 715 | print(f"\n=== edit_message ===") 716 | print(f"Starting edit for message ID: {message_id}") 717 | 718 | if message_id not in self.messages: 719 | print(f"Warning: Message {message_id} not found in chat history") 720 | return 721 | 722 | message = self.messages[message_id] 723 | if message.role != "user": 724 | print("Can only edit user messages") 725 | return 726 | 727 | message.start_edit() 728 | 729 | def save_chat_history(self): 730 | """Save the current chat history""" 731 | try: 732 | # Convert all messages to dictionaries using to_dict() 733 | history_data = [ 734 | ( 735 | msg.to_dict() 736 | if isinstance(msg, Message) 737 | else Message.from_dict(msg).to_dict() 738 | ) 739 | for msg in self.messages.values() 740 | ] 741 | self.chat_storage.save_chat_history(history_data) 742 | except Exception as e: 743 | print(f"Error saving chat history: {str(e)}") 744 | if DEBUG: 745 | import traceback 746 | 747 | traceback.print_exc() 748 | 749 | def update_chat_display(self): 750 | """Update the chat display with current content.""" 751 | DEBUG = False 752 | if DEBUG: 753 | print("\n=== update_chat_display ===") 754 | 755 | chat_content = [] 756 | 757 | # Check if messages is empty 758 | if not self.messages: 759 | if DEBUG: 760 | print("Empty messages, clearing display") 761 | self.chat_display.page().runJavaScript( 762 | """ 763 | try { 764 | updateChatContent([]); 765 | } catch (error) { 766 | console.error('Error updating chat content:', error); 767 | } 768 | """ 769 | ) 770 | return 771 | 772 | # Process messages if there are any 773 | for idx, message in enumerate(self.messages.values()): 774 | try: 775 | # Skip system messages 776 | if message.role == "system": 777 | continue 778 | 779 | sender = "user" if message.role == "user" else message.model 780 | message_id = message.id 781 | 782 | if DEBUG: 783 | print(f"\nProcessing message {idx}:") 784 | print(f"Sender: {sender}") 785 | print(f"Content: {message.content}") 786 | 787 | text_content = message.get_text() 788 | images_html = "" 789 | 790 | # Process images if any 791 | for item in message.content: 792 | if item.get("type") == "image" and "image_url" in item: 793 | img_url = item["image_url"]["url"] 794 | images_html += f'Screenshot' 795 | 796 | chat_content.append( 797 | { 798 | "sender": sender, 799 | "content": text_content, 800 | "images": images_html, 801 | "id": message_id, 802 | } 803 | ) 804 | 805 | if DEBUG: 806 | print(f"Added to chat_content - Text: {text_content[:50]}...") 807 | print(f"Has images: {'Yes' if images_html else 'No'}") 808 | 809 | except Exception as e: 810 | if DEBUG: 811 | print(f"Error processing message {idx}: {str(e)}") 812 | print(f"Message content: {message}") 813 | 814 | # Update the chat container content 815 | self.chat_display.page().runJavaScript( 816 | f""" 817 | try {{ 818 | updateChatContent({json.dumps(chat_content)}); 819 | }} catch (error) {{ 820 | console.error('Error updating chat content:', error); 821 | }} 822 | """ 823 | ) 824 | 825 | def get_messages_for_request(self, up_to_message_id=None): 826 | """Get messages formatted for provider request, optionally up to a specific message.""" 827 | messages_to_send = [] 828 | 829 | for msg_id, msg in self.messages.items(): 830 | # Create a copy of the message content 831 | message_data = msg.to_dict() 832 | messages_to_send.append(message_data) 833 | if msg_id == up_to_message_id: 834 | break 835 | 836 | return messages_to_send 837 | 838 | def start_provider_request(self, messages, screenshots=None, message_id=None): 839 | """Unified method to start a provider request.""" 840 | try: 841 | model = self.active_model or get_default_model() 842 | if DEBUG: 843 | print(f"Starting provider request with model: {model}") 844 | 845 | # Check if current model supports vision 846 | base_model = get_base_model_name(model) 847 | is_vision_model = ( 848 | base_model 849 | in self.chat_instance.settings_interface.vision_capable_models 850 | ) 851 | 852 | # If not a vision model, remove all image content from messages 853 | if not is_vision_model: 854 | for message in messages: 855 | message["content"] = [ 856 | item 857 | for item in message["content"] 858 | if item.get("type") != "image" 859 | ] 860 | screenshots = [] # Clear images for non-vision models 861 | 862 | self.chat_instance.provider_request_thread = ProviderRequest( 863 | messages, 864 | screenshots, 865 | model, 866 | message_id=message_id, 867 | temperature=self.chat_instance.settings_interface.temperature, 868 | context_size=self.chat_instance.settings_interface.context_size, 869 | ) 870 | 871 | thread = self.chat_instance.provider_request_thread 872 | thread.response_chunk_ready.connect(self.handle_response_chunk) 873 | thread.response_complete.connect(self.handle_response_complete) 874 | thread.start() 875 | 876 | # Update UI state 877 | self.is_receiving = True 878 | self.chat_instance.update_gradient_state() 879 | load_svg_button_icon(self.chat_instance.send_btn, self.ICONS / "stop.svg") 880 | self.chat_instance.send_btn.setObjectName("stopButton") 881 | 882 | except Exception as e: 883 | print(f"Error in start_provider_request: {str(e)}") 884 | if DEBUG: 885 | import traceback 886 | 887 | traceback.print_exc() 888 | -------------------------------------------------------------------------------- /gui/prompt_box.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtCore import ( 2 | QEvent, 3 | Qt, 4 | QTimer, 5 | ) 6 | from PyQt6.QtWidgets import ( 7 | QApplication, 8 | QTextEdit, 9 | QListWidget, 10 | ) 11 | from PyQt6.QtGui import ( 12 | QDropEvent, 13 | QImage, 14 | QKeySequence, 15 | ) 16 | 17 | 18 | class PromptBox(QTextEdit): 19 | def __init__(self, parent=None, chat_instance=None): 20 | super().__init__(parent) 21 | self.setAcceptDrops(True) 22 | self.setFixedHeight(50) 23 | self.setAcceptRichText(False) # Only allow plain text 24 | self.installEventFilter(self) 25 | self.chat_instance = chat_instance # Store a reference to the chat instance 26 | 27 | # Initialize suggestion list 28 | self.suggestion_list = QListWidget(self) 29 | self.suggestion_list.setObjectName("suggestionList") 30 | self.suggestion_list.setStyleSheet(self.chat_instance.styleSheet()) 31 | self.suggestion_list.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.ToolTip) 32 | self.suggestion_list.setFocusPolicy(Qt.FocusPolicy.NoFocus) 33 | self.suggestion_list.hide() 34 | self.suggestion_list.itemClicked.connect(self.insert_suggestion) 35 | 36 | # Connect textChanged signal directly 37 | self.textChanged.connect(self.update_suggestions) 38 | 39 | # Initialize the suggestion timer 40 | self._suggestion_timer = QTimer() 41 | self._suggestion_timer.setSingleShot(True) 42 | self._suggestion_timer.timeout.connect(self._update_suggestion_list) 43 | 44 | def dropEvent(self, event: QDropEvent): 45 | mime_data = event.mimeData() 46 | if mime_data.hasUrls(): 47 | for url in mime_data.urls(): 48 | file_path = url.toLocalFile() 49 | if file_path.lower().endswith(('.png', '.jpg', '.jpeg', '.gif', '.bmp')): 50 | image = QImage(file_path) 51 | if not image.isNull() and self.chat_instance: 52 | self.chat_instance.handle_pasted_image(image) 53 | event.acceptProposedAction() 54 | elif mime_data.hasImage(): 55 | image = QImage(mime_data.imageData()) 56 | if self.chat_instance: 57 | self.chat_instance.handle_pasted_image(image) 58 | event.acceptProposedAction() 59 | else: 60 | super().dropEvent(event) 61 | 62 | def handle_suggestion_navigation(self, key): 63 | """Handle keyboard navigation in the suggestion list.""" 64 | suggestion_list = self.suggestion_list 65 | current_row = suggestion_list.currentRow() 66 | if key == Qt.Key.Key_Up: 67 | new_row = ( 68 | max(0, current_row - 1) 69 | if current_row >= 0 70 | else suggestion_list.count() - 1 71 | ) 72 | else: # Key_Down 73 | new_row = ( 74 | min(suggestion_list.count() - 1, current_row + 1) 75 | if current_row >= 0 76 | else 0 77 | ) 78 | 79 | suggestion_list.setCurrentRow(new_row) 80 | return True 81 | 82 | def eventFilter(self, obj, event): 83 | if event.type() == QEvent.Type.KeyPress: 84 | if self.suggestion_list.isVisible(): 85 | if event.key() == Qt.Key.Key_Up: 86 | self.handle_suggestion_navigation(event.key()) 87 | return True 88 | elif event.key() == Qt.Key.Key_Down: 89 | self.handle_suggestion_navigation(event.key()) 90 | return True 91 | elif event.key() == Qt.Key.Key_Return: 92 | if self.suggestion_list.currentItem(): 93 | self.insert_suggestion(self.suggestion_list.currentItem()) 94 | return True 95 | elif event.key() == Qt.Key.Key_Escape: 96 | self.suggestion_list.hide() 97 | return True 98 | 99 | # Add ESC key handling for message editing 100 | if event.key() == Qt.Key.Key_Escape: 101 | if self.chat_instance.chat_box.current_editing_message: 102 | self.chat_instance.chat_box.current_editing_message.cancel_edit() 103 | return True 104 | 105 | # Handle Image paste 106 | if event.matches(QKeySequence.StandardKey.Paste): 107 | clipboard = QApplication.clipboard() 108 | mime_data = clipboard.mimeData() 109 | 110 | if mime_data.hasImage(): 111 | image = QImage(mime_data.imageData()) 112 | self.chat_instance.handle_pasted_image(image) 113 | return True 114 | 115 | # Handle Ctrl+Up and Ctrl+Down for message editing 116 | if event.modifiers() == Qt.KeyboardModifier.ControlModifier: 117 | if event.key() == Qt.Key.Key_Up: 118 | self.chat_instance.show_previous_message() 119 | return True 120 | elif event.key() == Qt.Key.Key_Down: 121 | self.chat_instance.show_next_message() 122 | return True 123 | 124 | # Regular Enter key handling 125 | if event.key() == Qt.Key.Key_Return or event.key() == Qt.Key.Key_Enter: 126 | if event.modifiers() == Qt.KeyboardModifier.ShiftModifier: 127 | cursor = self.textCursor() 128 | cursor.insertText("\n") 129 | return True 130 | else: 131 | if self.chat_instance.provider_online: 132 | self.chat_instance.send_message() 133 | return True 134 | 135 | return super().eventFilter(obj, event) 136 | 137 | 138 | def update_suggestions(self): 139 | # Get the current text and cursor position 140 | text = self.toPlainText() 141 | cursor = self.textCursor() 142 | current_line = text[:cursor.position()].split('\n')[-1] 143 | 144 | # Only process if line starts with @ and provider is online 145 | if current_line.startswith("@") and self.chat_instance and self.chat_instance.provider_online: 146 | # Check if cursor is after a space or model name 147 | after_at = current_line[1:] 148 | if " " in after_at: 149 | self.suggestion_list.hide() 150 | return 151 | 152 | # Store current search text and start timer 153 | self._current_search = current_line[1:].lower() 154 | self._suggestion_timer.start(150) # Delay of 150ms 155 | else: 156 | self.suggestion_list.hide() 157 | 158 | def _update_suggestion_list(self): 159 | """Actual update of suggestion list with debouncing.""" 160 | if not hasattr(self.chat_instance.settings_interface, 'model_names'): 161 | return 162 | 163 | self.suggestion_list.clear() 164 | filtered_models = [ 165 | model for model in self.chat_instance.settings_interface.model_names 166 | if self._current_search.lower() in model.lower() 167 | ] 168 | 169 | if filtered_models: 170 | self.suggestion_list.addItems(filtered_models) 171 | self.suggestion_list.setCurrentRow(0) # Select first item 172 | self.position_suggestion_list() 173 | self.suggestion_list.show() 174 | else: 175 | self.suggestion_list.hide() 176 | 177 | def position_suggestion_list(self): 178 | """Position the suggestion list below the cursor in the input field.""" 179 | cursor_rect = self.cursorRect() 180 | global_pos = self.mapToGlobal(cursor_rect.bottomLeft()) 181 | 182 | # Calculate the height based on the number of items (with a maximum) 183 | item_height = 25 # Approximate height per item 184 | num_items = min(self.suggestion_list.count(), 10) # Changed from self.chat_instance.suggestion_list 185 | list_height = num_items * item_height + 4 # Add small padding 186 | 187 | # Set size and position 188 | suggestion_width = 300 # Fixed width for suggestion list 189 | self.suggestion_list.setFixedSize(suggestion_width, list_height) # Changed 190 | 191 | # Position the list below the cursor 192 | self.suggestion_list.move(global_pos.x(), global_pos.y()) 193 | 194 | # Ensure the suggestion list stays within screen bounds 195 | screen = QApplication.primaryScreen().availableGeometry() 196 | list_rect = self.suggestion_list.geometry() # Changed 197 | 198 | # Adjust horizontal position if needed 199 | if list_rect.right() > screen.right(): 200 | x_pos = screen.right() - suggestion_width 201 | self.suggestion_list.move(x_pos, list_rect.y()) 202 | 203 | # Adjust vertical position if needed 204 | if list_rect.bottom() > screen.bottom(): 205 | y_pos = global_pos.y() - list_height - cursor_rect.height() 206 | self.suggestion_list.move(list_rect.x(), y_pos) 207 | 208 | def insert_suggestion(self, item): 209 | """Insert the selected suggestion into the input field.""" 210 | # Get current text and cursor position 211 | current_text = self.toPlainText() 212 | cursor = self.textCursor() 213 | position = cursor.position() 214 | 215 | # Find the @ before the cursor 216 | text_before_cursor = current_text[:position] 217 | at_index = text_before_cursor.rfind("@") 218 | 219 | if at_index != -1: 220 | # Replace the text from @ to cursor with the suggestion 221 | new_text = current_text[:at_index] + f"@{item.text()} " 222 | self.setPlainText(new_text) 223 | 224 | # Move cursor to end of inserted text 225 | cursor = self.textCursor() 226 | cursor.setPosition(len(new_text)) 227 | self.setTextCursor(cursor) 228 | 229 | self.suggestion_list.hide() 230 | self.setFocus() -------------------------------------------------------------------------------- /gui/settings.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | from utils.settings_manager import load_settings_from_file, save_settings_to_file 4 | from pathlib import Path 5 | from PyQt6.QtCore import ( 6 | Qt, 7 | ) 8 | from PyQt6.QtWidgets import ( 9 | QTextEdit, 10 | QListWidget, 11 | QWidget, 12 | QScrollArea, 13 | QLabel, 14 | QLineEdit, 15 | QPushButton, 16 | QSizePolicy, 17 | QVBoxLayout, 18 | QHBoxLayout, 19 | QListWidgetItem, 20 | QDialog, 21 | QGridLayout, 22 | QComboBox, 23 | QApplication, 24 | ) 25 | from PyQt6.QtGui import ( 26 | QDoubleValidator, 27 | QIntValidator, 28 | QIcon, 29 | QPixmap, 30 | QImage, 31 | ) 32 | from utils.provider_utils import ( 33 | get_ollama_url, 34 | request_models, 35 | get_openai_url, 36 | ) 37 | 38 | DEBUG = "-debug" in sys.argv 39 | 40 | 41 | @staticmethod 42 | def get_base_model_name(model_name): 43 | """Extract the base model name before the colon.""" 44 | return model_name.split(":")[0] if ":" in model_name else model_name 45 | 46 | 47 | def load_svg_button_icon(self, path): 48 | """ 49 | Load an SVG file and replace currentColor with specific color 50 | :param path: str path to SVG file 51 | :return: None 52 | """ 53 | style = self.styleSheet() 54 | color = "#e8eaed" # Default color if not found in stylesheet 55 | if "--icon-color:" in style: 56 | color = style.split("--icon-color:")[1].split(";")[0].strip() 57 | 58 | with open(path, "r") as file: 59 | svg_content = file.read() 60 | svg_content = svg_content.replace('fill="#e8eaed"', f'fill="{color}"') 61 | 62 | self.setIcon(QIcon(QPixmap.fromImage(QImage.fromData(svg_content.encode())))) 63 | self.icon_path = path 64 | 65 | 66 | class SettingsPage(QWidget): 67 | def __init__(self, parent=None, chat_instance=None): 68 | super().__init__(parent) 69 | 70 | self.ICONS = Path(__file__).parent.parent / "icons" 71 | # Replace direct settings loading with settings_manager 72 | self.settings = load_settings_from_file() 73 | self.chat_instance = chat_instance 74 | 75 | # Update these lines to properly handle provider initialization 76 | self.ollama_default_model = self.settings.get("ollama_default_model") 77 | self.openai_default_model = self.settings.get("openai_default_model") 78 | self.provider = self.settings.get("provider", "openai") # Add this line 79 | self.system_prompt = self.settings.get("system_prompt") 80 | self.ollama_url = self.settings["ollama_url"] 81 | self.temperature = self.settings.get("temperature") 82 | self.context_size = self.settings.get("context_size") 83 | self.vision_capable_models = set(self.settings.get("vision_capable_models", [])) 84 | self.theme = self.settings.get("theme", "dark") 85 | self.model_names = [] 86 | 87 | self.setWindowTitle("Settings") 88 | self.setGeometry(100, 100, 400, 300) 89 | 90 | settings_layout = QVBoxLayout(self) 91 | 92 | # Create a scroll area for all settings 93 | scroll_area = QScrollArea() 94 | scroll_area.setWidgetResizable(True) 95 | scroll_area.setHorizontalScrollBarPolicy( 96 | Qt.ScrollBarPolicy.ScrollBarAlwaysOff 97 | ) # Prevent horizontal scroll 98 | scroll_area.setMinimumWidth(100) # Set minimum width for scroll area 99 | scroll_content = QWidget() 100 | scroll_content.setMinimumWidth(100) 101 | scroll_layout = QVBoxLayout(scroll_content) 102 | scroll_layout.setContentsMargins(10, 10, 10, 10) # Add some padding 103 | scroll_content.setObjectName("scrollLayout") 104 | scroll_content.setStyleSheet(self.styleSheet()) 105 | 106 | # Add theme selection inside scroll area 107 | theme_container = QWidget() 108 | theme_container.setObjectName("provider_settingsPanel") 109 | theme_layout = QGridLayout(theme_container) 110 | theme_layout.setContentsMargins(5, 5, 5, 5) 111 | theme_layout.setColumnStretch(1, 1) 112 | 113 | # Theme selection combo box 114 | self.theme_label = QLabel("Theme:") 115 | self.theme_combo = QComboBox() 116 | self.theme_combo.currentTextChanged.connect(self.theme_combo_changed) 117 | self.theme_combo.setMinimumWidth(32) 118 | self.theme_combo.wheelEvent = lambda event: event.ignore() 119 | 120 | # Load themes from themes folder 121 | themes_dir = Path(__file__).parent.parent / "themes" 122 | theme_files = [f.stem for f in themes_dir.glob("*.qss")] 123 | self.theme_combo.addItems(theme_files) 124 | 125 | # Set current theme 126 | current_theme = self.settings.get("theme", "dark") 127 | index = self.theme_combo.findText(current_theme) 128 | if index >= 0: 129 | self.theme_combo.setCurrentIndex(index) 130 | 131 | # Add theme selection to layout 132 | theme_layout.addWidget(self.theme_label, 0, 0) 133 | theme_layout.addWidget(self.theme_combo, 0, 1) 134 | 135 | scroll_layout.addWidget(theme_container) 136 | 137 | # Create a container widget for provider settings with styling 138 | provider_container = QWidget() 139 | provider_container.setObjectName("provider_settingsPanel") 140 | provider_container.setSizePolicy( 141 | QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed 142 | ) # Allow horizontal expansion 143 | provider_container.setStyleSheet(self.styleSheet()) 144 | 145 | provider_layout = QGridLayout(provider_container) 146 | provider_layout.setContentsMargins(15, 15, 15, 15) 147 | 148 | # Make the second column of the grid stretch 149 | provider_layout.setColumnStretch(1, 1) 150 | 151 | # Provider selection combo box 152 | self.provider_label = QLabel("AI Provider:") 153 | self.provider_combo = QComboBox() 154 | self.provider_combo.setStyleSheet(self.chat_instance.styleSheet()) 155 | self.provider_combo.setMinimumWidth(32) 156 | self.provider_combo.addItems(["Ollama", "OpenAI"]) 157 | self.provider_combo.wheelEvent = lambda event: event.ignore() 158 | # Find the matching item ignoring case 159 | for i in range(self.provider_combo.count()): 160 | if self.provider_combo.itemText(i).lower() == self.provider: 161 | self.provider_combo.setCurrentIndex(i) 162 | break 163 | self.provider_combo.currentTextChanged.connect(self.on_provider_changed) 164 | 165 | provider_layout.addWidget(self.provider_label, 0, 0) 166 | provider_layout.addWidget(self.provider_combo, 0, 1) 167 | 168 | # Ollama URL input 169 | self.ollama_url_label = QLabel("Ollama URL:") 170 | self.ollama_url_label.setStyleSheet(self.chat_instance.styleSheet()) 171 | self.ollama_url_input = QLineEdit() 172 | self.ollama_url_input.setText(get_ollama_url()) 173 | self.ollama_url_input.setPlaceholderText("http://localhost:11434") 174 | self.ollama_url_input.setObjectName("ollamaUrlInput") 175 | self.ollama_url_input.setStyleSheet(self.chat_instance.styleSheet()) 176 | provider_layout.addWidget(self.ollama_url_label, 1, 0) 177 | provider_layout.addWidget(self.ollama_url_input, 1, 1) 178 | 179 | # OpenAI settings 180 | self.openai_url_label = QLabel("OpenAI URL:") 181 | self.openai_url_label.setStyleSheet(self.chat_instance.styleSheet()) 182 | self.openai_url_input = QLineEdit() 183 | self.openai_url_input.setText(get_openai_url()) 184 | self.openai_url_input.setPlaceholderText("https://api.openai.com/v1") 185 | self.openai_url_input.setObjectName("openaiUrlInput") 186 | self.openai_url_input.setStyleSheet(self.chat_instance.styleSheet()) 187 | provider_layout.addWidget(self.openai_url_label, 2, 0) 188 | provider_layout.addWidget(self.openai_url_input, 2, 1) 189 | 190 | self.openai_key_label = QLabel("OpenAI API Key:") 191 | self.openai_key_input = QLineEdit() 192 | self.openai_key_input.setEchoMode(QLineEdit.EchoMode.Password) 193 | self.openai_key_input.setText(self.settings.get("openai_key", "")) 194 | self.openai_key_label.setStyleSheet(self.chat_instance.styleSheet()) 195 | provider_layout.addWidget(self.openai_key_label, 3, 0) 196 | provider_layout.addWidget(self.openai_key_input, 3, 1) 197 | 198 | # Add provider container to scroll layout 199 | scroll_layout.addWidget(provider_container) 200 | 201 | # Add model selection 202 | self.model_label = QLabel("Available Models:") 203 | scroll_layout.addWidget(self.model_label) 204 | 205 | # Add search bar for models 206 | # Create container for search and reload button 207 | search_container = QWidget() 208 | search_layout = QHBoxLayout(search_container) 209 | search_layout.setContentsMargins(0, 0, 0, 0) 210 | 211 | # Add model search 212 | self.model_search = QLineEdit() 213 | self.model_search.setPlaceholderText("Search models...") 214 | self.model_search.textChanged.connect(self.filter_models) 215 | search_layout.addWidget(self.model_search) 216 | 217 | # Add model list reload button 218 | self.model_reload_button = QPushButton() 219 | self.model_reload_button.setFixedSize(30, 30) 220 | self.model_reload_button.clicked.connect( 221 | lambda: self.reload_models(update_ui=True) 222 | ) 223 | self.model_reload_button.setStyleSheet(self.styleSheet()) 224 | load_svg_button_icon(self.model_reload_button, self.ICONS / "refresh.svg") 225 | search_layout.addWidget(self.model_reload_button) 226 | 227 | scroll_layout.addWidget(search_container) 228 | 229 | # Model list 230 | self.model_list = QListWidget() 231 | self.model_list.setMinimumHeight(100) 232 | self.model_list.setSizePolicy( 233 | QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Expanding 234 | ) 235 | self.model_list.setObjectName("modelList") 236 | scroll_layout.addWidget(self.model_list) 237 | 238 | # Add temperature setting 239 | self.temperature_label = QLabel("Temperature:") 240 | scroll_layout.addWidget(self.temperature_label) 241 | 242 | self.temperature_input = QLineEdit() 243 | self.temperature_input.setPlaceholderText("default") 244 | temperature_validator = QDoubleValidator(0.0, 1.0, 2, self) 245 | self.temperature_input.setValidator(temperature_validator) 246 | scroll_layout.addWidget(self.temperature_input) 247 | 248 | # Add context size setting 249 | self.context_size_label = QLabel("Context Size:") 250 | scroll_layout.addWidget(self.context_size_label) 251 | 252 | self.context_size_input = QLineEdit() 253 | self.context_size_input.setPlaceholderText("default") 254 | context_size_validator = QIntValidator(0, 65536, self) 255 | self.context_size_input.setValidator(context_size_validator) 256 | scroll_layout.addWidget(self.context_size_input) 257 | 258 | # Add system prompt setting 259 | self.system_prompt_label = QLabel("System Prompt:") 260 | scroll_layout.addWidget(self.system_prompt_label) 261 | 262 | # Create a container for system prompt and button 263 | system_prompt_container = QWidget() 264 | system_prompt_container.setSizePolicy( 265 | QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed 266 | ) 267 | system_prompt_layout = QVBoxLayout(system_prompt_container) 268 | system_prompt_layout.setContentsMargins(0, 0, 0, 0) 269 | 270 | # Add system prompt input 271 | self.system_prompt_input = QTextEdit() 272 | self.system_prompt_input.setPlaceholderText("Enter system prompt here...") 273 | self.system_prompt_input.setSizePolicy( 274 | QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Fixed 275 | ) 276 | self.system_prompt_input.setFixedHeight(100) 277 | 278 | # Add prompt selection button 279 | prompt_button = QPushButton("") 280 | prompt_button.setFixedSize(24, 24) 281 | prompt_button.setObjectName("systemPromptButton") 282 | prompt_button.setStyleSheet(self.chat_instance.styleSheet()) 283 | load_svg_button_icon(prompt_button, self.ICONS / "browse.svg") 284 | prompt_button.clicked.connect(self.show_prompt_selector) 285 | 286 | prompt_button_container = QWidget() 287 | prompt_button_container.setSizePolicy( 288 | QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Fixed 289 | ) 290 | prompt_button_layout = QHBoxLayout(prompt_button_container) 291 | prompt_button_layout.setContentsMargins(0, 0, 0, 0) 292 | 293 | prompt_button_layout.addWidget(self.system_prompt_input) 294 | prompt_button_layout.addWidget(prompt_button) 295 | 296 | system_prompt_layout.addWidget(prompt_button_container) 297 | scroll_layout.addWidget(system_prompt_container) 298 | 299 | scroll_layout.addStretch(1) 300 | scroll_content.setLayout(scroll_layout) 301 | scroll_area.setWidget(scroll_content) 302 | settings_layout.addWidget(scroll_area) 303 | 304 | # Button layout outside scroll area 305 | button_layout = QHBoxLayout() 306 | self.apply_button = QPushButton("Apply") 307 | self.apply_button.clicked.connect(self.apply_settings) 308 | button_layout.addWidget(self.apply_button) 309 | 310 | self.cancel_button = QPushButton("Cancel") 311 | self.cancel_button.setObjectName("cancelButton") 312 | self.cancel_button.clicked.connect(self.cancel_settings) 313 | button_layout.addWidget(self.cancel_button) 314 | 315 | settings_layout.addLayout(button_layout) 316 | 317 | # Update visibility based on selected provider 318 | self.update_provider_fields() 319 | 320 | def filter_models(self, text): 321 | """Filter the model list based on the search text.""" 322 | search_text = text.lower() 323 | for index in range(self.model_list.count()): 324 | item = self.model_list.item(index) 325 | model_name = self.get_selected_model_name(item) 326 | if model_name: 327 | item.setHidden(search_text not in model_name.lower()) 328 | else: 329 | item.setHidden(True) 330 | 331 | def get_selected_model_name(self, item): 332 | # Retrieve the custom widget associated with the item 333 | custom_widget = self.model_list.itemWidget(item) 334 | if not custom_widget: 335 | return None 336 | 337 | # Find the QLabel within the custom widget 338 | label = custom_widget.findChild(QLabel) 339 | if label: 340 | return label.text() 341 | 342 | return None 343 | 344 | def load_settings(self): 345 | """Load settings using settings_manager""" 346 | self.settings = load_settings_from_file() 347 | 348 | # Update instance variables 349 | self.ollama_default_model = self.settings.get("ollama_default_model") 350 | self.openai_default_model = self.settings.get("openai_default_model") 351 | self.temperature = self.settings.get("temperature") 352 | self.context_size = self.settings.get("context_size") 353 | self.system_prompt = self.settings.get("system_prompt") 354 | self.ollama_url = self.settings.get("ollama_url") 355 | self.vision_capable_models = set(self.settings.get("vision_capable_models", [])) 356 | 357 | self.theme_combo.setCurrentText(self.settings.get("theme", "dark")) 358 | 359 | # Update UI elements 360 | self.temperature_input.setText( 361 | str(self.temperature) if self.temperature is not None else "" 362 | ) 363 | self.context_size_input.setText( 364 | str(self.context_size) if self.context_size is not None else "" 365 | ) 366 | self.system_prompt_input.setPlainText(self.system_prompt) 367 | self.ollama_url_input.setText(self.ollama_url) 368 | 369 | return self.settings 370 | 371 | def apply_settings(self): 372 | """Save settings using settings_manager""" 373 | # Get values from UI 374 | temperature = self.temperature_input.text() 375 | context_size = self.context_size_input.text() 376 | 377 | # Validate temperature 378 | try: 379 | self.temperature = float(temperature) 380 | self.temperature = max(0.0, min(self.temperature, 1.0)) 381 | except ValueError: 382 | self.temperature = None 383 | 384 | # Validate context size 385 | try: 386 | self.context_size = int(context_size) 387 | self.context_size = max(0, min(self.context_size, 65536)) 388 | except ValueError: 389 | self.context_size = None 390 | 391 | # Update settings 392 | self.system_prompt = self.system_prompt_input.toPlainText() 393 | 394 | # Get current settings to preserve existing values 395 | current_settings = load_settings_from_file() 396 | 397 | # Store both providers' settings 398 | ollama_url = self.ollama_url_input.text() 399 | openai_url = self.openai_url_input.text() 400 | openai_key = self.openai_key_input.text() 401 | 402 | # Update the settings dictionary 403 | save_settings_to_file( 404 | { 405 | **current_settings, # Preserve all existing settings 406 | "theme": self.theme_combo.currentText(), 407 | "provider": self.provider_combo.currentText().lower(), 408 | "openai_url": openai_url, 409 | "openai_key": openai_key, 410 | "ollama_url": ollama_url, 411 | "ollama_default_model": self.ollama_default_model, 412 | "openai_default_model": self.openai_default_model, 413 | "temperature": self.temperature, 414 | "context_size": self.context_size, 415 | "system_prompt": self.system_prompt, 416 | "vision_capable_models": sorted(list(self.vision_capable_models)), 417 | } 418 | ) 419 | 420 | # Force theme update 421 | self.current_theme = self.theme_combo.currentText() 422 | self.update_theme(self.current_theme) 423 | 424 | self.load_settings() 425 | self.chat_instance.toggle_settings() 426 | 427 | def theme_combo_changed(self): 428 | theme_name = self.theme_combo.currentText() 429 | self.update_theme(theme_name) 430 | 431 | def update_theme(self, theme_name): 432 | theme_path = Path(__file__).parent.parent / "themes" / f"{theme_name}.qss" 433 | if theme_path.exists(): 434 | with open(theme_path, "r") as f: 435 | stylesheet = f.read() 436 | self.chat_instance.setStyleSheet(stylesheet) 437 | # Update style for all child widgets 438 | for child in self.chat_instance.findChildren(QWidget): 439 | child.setStyleSheet(stylesheet) 440 | if isinstance(child, QComboBox): 441 | # Update the combo box popup/dropdown menu 442 | child.view().setStyleSheet(stylesheet) 443 | if isinstance(child, QPushButton) and child.icon(): 444 | if hasattr(child, "icon_path"): 445 | load_svg_button_icon(child, child.icon_path) 446 | child.style().unpolish(child) 447 | child.style().polish(child) 448 | child.update() 449 | 450 | self.chat_instance.chat_box.update_webview_colors() 451 | 452 | def reload_models(self, update_ui=False): 453 | """Reload the model list""" 454 | try: 455 | # Force update of the UI 456 | QApplication.processEvents() 457 | 458 | # Now load new models 459 | self.model_names = [] 460 | self.model_names = sorted( 461 | request_models(self.provider_combo.currentText().lower()) 462 | ) 463 | 464 | if update_ui: 465 | self.setCursor(Qt.CursorShape.WaitCursor) 466 | 467 | # Clear existing items and their widgets 468 | while self.model_list.count() > 0: 469 | item = self.model_list.takeItem(0) 470 | widget = self.model_list.itemWidget(item) 471 | if widget: 472 | widget.deleteLater() 473 | del item 474 | 475 | self.model_list.clear() # Ensure list is visually cleared 476 | self.update_list() 477 | finally: 478 | self.chat_instance.provider_online = not self.model_names[0] in [ 479 | "Error loading models" 480 | ] 481 | if update_ui: 482 | self.setCursor(Qt.CursorShape.ArrowCursor) 483 | 484 | def update_list(self): 485 | """Load models based on selected provider""" 486 | self.model_list.clear() 487 | 488 | # Create items with fixed button positions 489 | for model_name in self.model_names: 490 | item = QListWidgetItem(self.model_list) 491 | base_model = get_base_model_name(model_name) # Get base model name 492 | 493 | # Create a widget to hold the model name and icons 494 | widget = QWidget() 495 | layout = QHBoxLayout(widget) 496 | layout.setContentsMargins(5, 2, 5, 2) 497 | layout.setSpacing(0) # Remove spacing between elements 498 | 499 | # Create a fixed-width container for buttons 500 | button_container = QWidget() 501 | button_container.setFixedWidth(56) # Adjust width based on your buttons 502 | 503 | button_layout = QHBoxLayout(button_container) 504 | button_layout.setContentsMargins(0, 0, 0, 0) 505 | button_layout.setSpacing(2) 506 | button_layout.setAlignment(Qt.AlignmentFlag.AlignLeft) 507 | 508 | # Add button container first 509 | layout.addWidget(button_container, 0, Qt.AlignmentFlag.AlignLeft) 510 | 511 | # Add model name label with elision 512 | label = QLabel(model_name) 513 | label.setStyleSheet("text-align: left; padding-left: 0px;") 514 | label.setMinimumWidth(50) 515 | label.setMaximumWidth(300) 516 | layout.addWidget(label, 1, Qt.AlignmentFlag.AlignLeft) 517 | 518 | if model_name not in ["Error loading models"]: 519 | # Add default model button 520 | default_btn = QPushButton() 521 | default_btn.setFixedSize(24, 24) 522 | default_btn.setObjectName("modelDefaultButton") 523 | default_btn.setProperty("model_name", model_name) 524 | current_default = ( 525 | self.ollama_default_model 526 | if self.provider_combo.currentText().lower() == "ollama" 527 | else self.openai_default_model 528 | ) 529 | default_btn.setProperty("is_default", model_name == current_default) 530 | default_btn.setStyleSheet(self.chat_instance.styleSheet()) 531 | load_svg_button_icon(default_btn, self.ICONS / "default.svg") 532 | default_btn.clicked.connect( 533 | lambda checked, m=model_name, b=default_btn: self.handle_default_model_click( 534 | m, b 535 | ) 536 | ) 537 | button_layout.addWidget(default_btn) 538 | 539 | # Add camera icon 540 | camera_btn = QPushButton() 541 | camera_btn.setFixedSize(24, 24) 542 | camera_btn.setObjectName("modelCameraButton") 543 | camera_btn.setProperty("model_name", model_name) 544 | camera_btn.setProperty( 545 | "enabled_state", base_model in self.vision_capable_models 546 | ) 547 | camera_btn.clicked.connect( 548 | lambda checked, m=model_name, b=camera_btn: self.handle_model_camera_click( 549 | m, b 550 | ) 551 | ) 552 | self.update_camera_button_style(camera_btn) 553 | button_layout.addWidget(camera_btn) 554 | 555 | # Update default button style 556 | self.update_default_button_style(default_btn) 557 | 558 | # Set the custom widget as the item's widget 559 | item.setSizeHint(widget.sizeHint()) 560 | self.model_list.setItemWidget(item, widget) 561 | 562 | def handle_default_model_click(self, model_name, button): 563 | """Handle clicking the default model button.""" 564 | # Update the default model 565 | if self.provider_combo.currentText().lower() == "ollama": 566 | self.ollama_default_model = model_name 567 | else: 568 | self.openai_default_model = model_name 569 | 570 | self.selected_model = model_name 571 | 572 | # Update all default buttons 573 | for i in range(self.model_list.count()): 574 | item = self.model_list.item(i) 575 | widget = self.model_list.itemWidget(item) 576 | if widget: 577 | default_btn = widget.findChild(QPushButton, "modelDefaultButton") 578 | if default_btn: 579 | default_btn.setProperty( 580 | "is_default", default_btn.property("model_name") == model_name 581 | ) 582 | self.update_default_button_style(default_btn) 583 | # default_btn.style().unpolish(default_btn) 584 | # default_btn.style().polish(default_btn) 585 | 586 | def update_default_button_style(self, button): 587 | """Update the default button style based on its state.""" 588 | is_default = button.property("is_default") 589 | button.setObjectName("modelDefaultButton") 590 | button.setStyleSheet(self.chat_instance.styleSheet()) 591 | button.setProperty("selected", "true" if is_default else "false") 592 | button.style().unpolish(button) 593 | button.style().polish(button) 594 | 595 | def handle_model_selection(self, item): 596 | """Handle double-click to select a model.""" 597 | if item: 598 | print(f"Selected model: {self.selected_model}") 599 | 600 | def update_camera_button_style(self, button): 601 | """Update the camera button style based on its enabled state.""" 602 | is_enabled = button.property("enabled_state") 603 | button.setStyleSheet(self.chat_instance.styleSheet()) 604 | if is_enabled: 605 | load_svg_button_icon(button, self.ICONS / "vision.svg") 606 | else: 607 | load_svg_button_icon(button, self.ICONS / "vision_disabled.svg") 608 | 609 | def handle_model_camera_click(self, model_name, button): 610 | """Toggle vision capability for a model and all its variants.""" 611 | base_name = get_base_model_name(model_name) 612 | current_state = button.property("enabled_state") 613 | new_state = not current_state 614 | 615 | # Update the set of vision-capable models using base name 616 | if new_state: 617 | self.vision_capable_models.add(base_name) 618 | else: 619 | self.vision_capable_models.discard(base_name) 620 | 621 | # Update all related model buttons 622 | for index in range(self.model_list.count()): 623 | item = self.model_list.item(index) 624 | widget = self.model_list.itemWidget(item) 625 | if widget: 626 | related_camera_btn = widget.findChild(QPushButton, "modelCameraButton") 627 | if related_camera_btn: 628 | related_model = related_camera_btn.property("model_name") 629 | if get_base_model_name(related_model) == base_name: 630 | # Update the button state 631 | related_camera_btn.setProperty("enabled_state", new_state) 632 | self.update_camera_button_style(related_camera_btn) 633 | 634 | def hide_prompt_selector(self): 635 | if hasattr(self, "prompt_overlay"): 636 | # Save all changes when closing 637 | if hasattr(self, "modified_prompts"): 638 | settings = load_settings_from_file() 639 | settings["saved_prompts"] = list(self.modified_prompts.values()) 640 | save_settings_to_file(settings) 641 | self.prompt_overlay.hide() 642 | self.prompt_overlay.deleteLater() # Add this line to properly delete the widget 643 | delattr(self, "prompt_overlay") # Remove the reference 644 | 645 | def cancel_settings(self): 646 | # Close prompt browser if it's open 647 | if hasattr(self, "prompt_overlay"): 648 | self.hide_prompt_selector() 649 | 650 | theme = self.settings.get("theme", "dark") 651 | if theme != self.theme_combo.currentText(): 652 | self.update_theme(theme) 653 | print(f"Updated theme to {theme}") 654 | 655 | load_svg_button_icon( 656 | self.chat_instance.settings_btn, self.ICONS / "settings.svg" 657 | ) 658 | self.hide() 659 | 660 | def hide(self): 661 | """Hide settings and return to main chat interface.""" 662 | super().hide() 663 | self.chat_instance.stacked_widget.setCurrentWidget( 664 | self.chat_instance.chat_interface 665 | ) 666 | 667 | def show_prompt_selector(self): 668 | if not hasattr(self, "prompt_overlay"): 669 | self.prompt_overlay = QWidget(self) 670 | # Store original prompts for comparison when saving 671 | self.original_prompts = {} 672 | self.modified_prompts = {} 673 | 674 | overlay_layout = QVBoxLayout(self.prompt_overlay) 675 | 676 | # Scroll area for prompts 677 | scroll = QScrollArea() 678 | scroll.setWidgetResizable(True) 679 | scroll.setObjectName( 680 | "scrollArea" 681 | ) # Use same object name as settings scroll 682 | 683 | scroll_content = QWidget() 684 | scroll_content.setObjectName("scrollLayout") 685 | self.prompt_list_layout = QVBoxLayout(scroll_content) 686 | self.prompt_list_layout.setSpacing(10) 687 | self.prompt_list_layout.setContentsMargins(10, 10, 10, 10) 688 | self.prompt_list_layout.addStretch() 689 | 690 | scroll.setWidget(scroll_content) 691 | overlay_layout.addWidget(scroll) 692 | 693 | # Bottom buttons 694 | button_layout = QHBoxLayout() 695 | add_button = QPushButton("Add New Prompt") 696 | add_button.clicked.connect(self.add_new_prompt) 697 | button_layout.addWidget(add_button) 698 | 699 | close_button = QPushButton("Close") 700 | close_button.setObjectName("cancelButton") 701 | close_button.clicked.connect(self.hide_prompt_selector) 702 | button_layout.addWidget(close_button) 703 | 704 | overlay_layout.addLayout(button_layout) 705 | 706 | # Load fresh prompts when showing 707 | settings = load_settings_from_file() 708 | self.original_prompts = {p: p for p in settings.get("saved_prompts", [])} 709 | self.modified_prompts = self.original_prompts.copy() 710 | 711 | self.update_prompt_list() 712 | self.prompt_overlay.resize(self.size()) 713 | self.prompt_overlay.show() 714 | 715 | def hide_prompt_selector(self): 716 | if hasattr(self, "prompt_overlay"): 717 | # Save all changes when closing 718 | if hasattr(self, "modified_prompts"): 719 | settings = load_settings_from_file() 720 | settings["saved_prompts"] = list(self.modified_prompts.values()) 721 | save_settings_to_file(settings) 722 | self.prompt_overlay.hide() 723 | 724 | def update_prompt_list(self): 725 | # Clear existing prompts 726 | while self.prompt_list_layout.count() > 1: 727 | item = self.prompt_list_layout.takeAt(0) 728 | if item.widget(): 729 | item.widget().deleteLater() 730 | 731 | # Add prompts using the in-memory copies 732 | for original_prompt, current_prompt in self.modified_prompts.items(): 733 | prompt_widget = QWidget() 734 | prompt_widget.setFixedHeight(100) 735 | prompt_widget.setObjectName("promptItem") 736 | 737 | layout = QHBoxLayout(prompt_widget) 738 | layout.setContentsMargins(0, 0, 0, 0) 739 | layout.setSpacing(5) 740 | 741 | # Text container - remove extra margins 742 | text_container = QWidget() 743 | text_container.setObjectName("promptTextContainer") 744 | text_layout = QHBoxLayout(text_container) 745 | text_layout.setContentsMargins(0, 0, 0, 0) # Reduced margins 746 | 747 | # Prompt text edit 748 | preview = QTextEdit() 749 | preview.setPlainText(current_prompt) 750 | preview.setObjectName("promptText") 751 | preview.textChanged.connect( 752 | lambda p=preview, orig=original_prompt: self.handle_prompt_edit(p, orig) 753 | ) 754 | text_layout.addWidget(preview) 755 | 756 | layout.addWidget(text_container, stretch=1) # Add stretch factor 757 | 758 | # Button container 759 | button_container = QWidget() 760 | button_container.setFixedWidth(24) 761 | button_layout = QVBoxLayout(button_container) 762 | button_layout.setSpacing(0) 763 | button_layout.setContentsMargins(0, 0, 0, 0) 764 | 765 | # Select button - update to pass original_prompt as key 766 | select_button = QPushButton("") 767 | select_button.setFixedSize(24, 24) 768 | select_button.setObjectName("promptSelectButton") 769 | select_button.setStyleSheet(self.chat_instance.styleSheet()) 770 | load_svg_button_icon(select_button, self.ICONS / "default.svg") 771 | select_button.clicked.connect( 772 | lambda _, key=original_prompt: self.select_prompt(key) 773 | ) 774 | 775 | # Delete button 776 | delete_button = QPushButton("") 777 | delete_button.setFixedSize(24, 24) 778 | delete_button.setObjectName("promptDeleteButton") 779 | delete_button.setStyleSheet(self.chat_instance.styleSheet()) 780 | load_svg_button_icon(delete_button, self.ICONS / "clear.svg") 781 | delete_button.clicked.connect( 782 | lambda _, p=original_prompt, w=preview: self.delete_prompt( 783 | p, w.toPlainText() 784 | ) 785 | ) 786 | 787 | button_layout.addWidget(select_button) 788 | button_layout.addWidget(delete_button) 789 | 790 | layout.addWidget(button_container) 791 | 792 | self.prompt_list_layout.insertWidget( 793 | self.prompt_list_layout.count() - 1, prompt_widget 794 | ) 795 | 796 | def handle_prompt_edit(self, text_edit, original_prompt): 797 | """Store prompt changes in memory without saving immediately""" 798 | new_text = text_edit.toPlainText() 799 | if original_prompt in self.original_prompts: 800 | self.modified_prompts[original_prompt] = new_text 801 | 802 | def select_prompt(self, prompt_key): 803 | """Set the selected prompt as the system prompt using the current modified version""" 804 | if prompt_key in self.modified_prompts: 805 | current_text = self.modified_prompts[prompt_key] 806 | self.system_prompt_input.setPlainText(current_text) 807 | self.hide_prompt_selector() 808 | 809 | def delete_prompt(self, original_prompt, current_prompt): 810 | """Remove prompt from in-memory storage""" 811 | if original_prompt in self.modified_prompts: 812 | del self.modified_prompts[original_prompt] 813 | del self.original_prompts[original_prompt] 814 | self.update_prompt_list() 815 | 816 | def add_new_prompt(self): 817 | dialog = QDialog(self) 818 | dialog.setWindowTitle("Add New Prompt") 819 | layout = QVBoxLayout(dialog) 820 | 821 | prompt_input = QTextEdit() 822 | prompt_input.setPlaceholderText("Enter new prompt...") 823 | layout.addWidget(prompt_input) 824 | 825 | button_box = QHBoxLayout() 826 | save_button = QPushButton("Save") 827 | cancel_button = QPushButton("Cancel") 828 | 829 | save_button.clicked.connect( 830 | lambda: self.save_new_prompt(prompt_input.toPlainText(), dialog) 831 | ) 832 | cancel_button.clicked.connect(dialog.reject) 833 | 834 | button_box.addWidget(save_button) 835 | button_box.addWidget(cancel_button) 836 | layout.addLayout(button_box) 837 | 838 | dialog.exec() 839 | 840 | def save_new_prompt(self, prompt, dialog): 841 | if prompt.strip(): 842 | # Add to in-memory storage 843 | self.original_prompts[prompt] = prompt 844 | self.modified_prompts[prompt] = prompt 845 | self.update_prompt_list() 846 | dialog.accept() 847 | 848 | def on_provider_changed(self, provider): 849 | """Handle provider change in combo box""" 850 | self.update_provider_fields() 851 | 852 | def update_provider_fields(self): 853 | """Update visibility of provider-specific fields""" 854 | is_ollama = self.provider_combo.currentText() == "Ollama" 855 | 856 | # Ollama fields 857 | self.ollama_url_label.setVisible(is_ollama) 858 | self.ollama_url_input.setVisible(is_ollama) 859 | 860 | # OpenAI fields 861 | self.openai_url_label.setVisible(not is_ollama) 862 | self.openai_url_input.setVisible(not is_ollama) 863 | self.openai_key_label.setVisible(not is_ollama) 864 | self.openai_key_input.setVisible(not is_ollama) 865 | 866 | # Reload models 867 | self.reload_models(update_ui=True) 868 | -------------------------------------------------------------------------------- /html/chat_template.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | 13 | Chat Application 14 | 164 | 442 | 443 | 444 |
445 |
446 | App Icon 447 |

Checking connection status...

448 |
449 |
450 |
451 | 452 | 453 | -------------------------------------------------------------------------------- /icons/app_icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredconex/PixelLlama/f6cb452e56f138ba22b5d593b43affb7e17f3109/icons/app_icon.png -------------------------------------------------------------------------------- /icons/background.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/fredconex/PixelLlama/f6cb452e56f138ba22b5d593b43affb7e17f3109/icons/background.png -------------------------------------------------------------------------------- /icons/browse.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/camera.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/clear.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/close.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/default.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/drop_down_arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/edit.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/go_previous.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/left_arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/monitor.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/refresh.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/right_arrow.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/send.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/settings.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/stop.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/vision.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/vision_disabled.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/zoom_in.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /icons/zoom_out.svg: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | PyQt6 2 | PyQt6-WebEngine 3 | requests -------------------------------------------------------------------------------- /run.bat: -------------------------------------------------------------------------------- 1 | @echo off 2 | setlocal enabledelayedexpansion 3 | 4 | :: PixelLlama info 5 | echo PixelLlama - version 0.95b 6 | echo Launching... 7 | 8 | :: Set the name of your virtual environment 9 | set VENV_NAME=.env 10 | 11 | :: Check if the virtual environment exists by looking for pyvenv.cfg 12 | if not exist %VENV_NAME%\Scripts\python.exe ( 13 | echo Creating virtual environment... 14 | python -m venv %VENV_NAME% 15 | ) 16 | 17 | :: Activate the virtual environment 18 | call %VENV_NAME%\Scripts\activate.bat 19 | 20 | :: Check if required packages are installed by comparing installed versions 21 | for %%i in (PyQt6 PyQt6-WebEngine requests) do ( 22 | pip show %%i >nul 2>&1 23 | if errorlevel 1 ( 24 | set need_install=1 25 | ) 26 | ) 27 | 28 | :: Check if requirements are installed 29 | set REQUIREMENTS_FILE=requirements.txt 30 | 31 | :: Install or upgrade the required packages only if needed 32 | if defined need_install ( 33 | echo Installing/Upgrading required packages... 34 | pip install -r requirements.txt 35 | ) 36 | 37 | :: Run the Python script using pythonw 38 | echo Running... 39 | start "" pythonw main.py %* 40 | 41 | :: Deactivate the virtual environment 42 | deactivate 43 | 44 | :: Exit the batch file (closes the terminal) 45 | exit 46 | -------------------------------------------------------------------------------- /run.sh: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | 3 | # PixelLlama info 4 | echo "PixelLlama - version 0.95b" 5 | echo "Launching PixelLlama..." 6 | 7 | # Set the name of your virtual environment 8 | VENV_NAME=".venv" 9 | 10 | # Check if the virtual environment exists by looking for pyvenv.cfg 11 | if [ ! -f "$VENV_NAME/bin/python" ]; then 12 | echo "Creating virtual environment..." 13 | python3 -m venv "$VENV_NAME" 14 | fi 15 | 16 | # Activate the virtual environment 17 | source "$VENV_NAME/bin/activate" 18 | 19 | # Check if required packages are installed by comparing installed versions 20 | for i in PyQt6 PyQt6-WebEngine requests; do 21 | pip show "$i" > /dev/null 2>&1 22 | if [ $? -ne 0 ]; then 23 | need_install=1 24 | fi 25 | done 26 | 27 | # Check if requirements are installed 28 | REQUIREMENTS_FILE="requirements.txt" 29 | 30 | # Install or upgrade the required packages only if needed 31 | if [ -n "$need_install" ]; then 32 | echo "Installing/Upgrading required packages..." 33 | pip install -r "$REQUIREMENTS_FILE" 34 | fi 35 | 36 | # Run the Python script using python 37 | echo "Running PixelLlama..." 38 | python main.py "$@" 39 | 40 | # Deactivate the virtual environment 41 | deactivate 42 | 43 | # Exit the script 44 | exit -------------------------------------------------------------------------------- /themes/dark.qss: -------------------------------------------------------------------------------- 1 | QWidget#mainWidget { 2 | background-color: #1E1E1E; 3 | border-radius: 20px; 4 | border: 1px solid #333333; 5 | } 6 | 7 | QWidget#outerWidget { 8 | background-color: #2D2D2D; 9 | border-radius: 20px; 10 | } 11 | 12 | QWidget#chatMessage { 13 | background-color: #2D2D2D; 14 | color: #D4D4D4; 15 | } 16 | 17 | QWidget#chatMessageUser { 18 | border-color: #7289DA; 19 | } 20 | 21 | QWidget#chatMessageAssistant { 22 | background-color: #252525; 23 | border-color: #7289DA; 24 | } 25 | 26 | QScrollBar:vertical { 27 | border: none; 28 | background: transparent; 29 | width: 8px; 30 | margin: 0px 2px 0px 2px; 31 | } 32 | 33 | QScrollBar::handle:vertical { 34 | background: #404040; 35 | border-radius: 4px; 36 | min-height: 20px; 37 | } 38 | 39 | QScrollBar::handle:vertical:hover { 40 | background: #505050; 41 | } 42 | 43 | QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { 44 | height: 0px; 45 | } 46 | 47 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 48 | background: none; 49 | } 50 | 51 | QScrollBar:horizontal { 52 | border: none; 53 | background: transparent; 54 | height: 8px; 55 | margin: 2px 0px 2px 0px; 56 | } 57 | 58 | QScrollBar::handle:horizontal { 59 | background: #404040; 60 | border-radius: 4px; 61 | min-width: 20px; 62 | } 63 | 64 | QScrollBar::handle:horizontal:hover { 65 | background: #505050; 66 | } 67 | 68 | QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { 69 | width: 0px; 70 | } 71 | 72 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { 73 | background: none; 74 | } 75 | 76 | QWidget#provider_settingsPanel { 77 | background-color: #252525; 78 | border: 1px solid #333333; 79 | padding: 10px; 80 | font-size: 14px; 81 | color: #D4D4D4; 82 | border-radius: 10px; 83 | } 84 | 85 | QWidget#systemPromptButtonContainer { 86 | background-color: #252525; 87 | border: 1px solid #333333; 88 | padding: 10px; 89 | font-size: 14px; 90 | color: #D4D4D4; 91 | border-radius: 10px; 92 | } 93 | 94 | QScrollArea { 95 | background-color: #1E1E1E; 96 | border: none; 97 | } 98 | 99 | QWidget#scrollLayout { 100 | background-color: #252525; 101 | border: 1px solid #333333; 102 | padding: 10px; 103 | font-size: 14px; 104 | color: #D4D4D4; 105 | border-radius: 10px; 106 | } 107 | 108 | QLabel { 109 | color: #D4D4D4; 110 | font-size: 14px; 111 | } 112 | 113 | QLineEdit { 114 | background-color: #2D2D2D; 115 | border: 1px solid #404040; 116 | padding: 5px; 117 | font-size: 12px; 118 | color: #D4D4D4; 119 | border-radius: 5px; 120 | } 121 | 122 | QTextEdit { 123 | background-color: #2D2D2D; 124 | border: 1px solid #404040; 125 | padding: 10px; 126 | font-size: 14px; 127 | color: #D4D4D4; 128 | border-radius: 5px; 129 | } 130 | 131 | QTextBrowser { 132 | background-color: #2D2D2D; 133 | border: 1px solid #404040; 134 | padding: 10px; 135 | font-size: 14px; 136 | color: #D4D4D4; 137 | border-radius: 10px; 138 | } 139 | 140 | QPushButton { 141 | background-color: #333333; 142 | color: #D4D4D4; 143 | border: 1px solid #404040; 144 | padding: 10px 20px; 145 | border-radius: 4px; 146 | font-size: 14px; 147 | font-weight: bold; 148 | } 149 | 150 | QPushButton:hover { 151 | background-color: #404040; 152 | } 153 | 154 | QPushButton#verticalButton { 155 | background-color: #2D2D2D; 156 | margin-left: 2px; 157 | border: 2px solid #404040; 158 | border-top-left-radius: 0px; 159 | border-bottom-left-radius: 0px; 160 | border-top-right-radius: 10px; 161 | border-bottom-right-radius: 10px; 162 | } 163 | 164 | QPushButton#verticalButton:hover { 165 | background-color: #333333; 166 | } 167 | 168 | QComboBox { 169 | background-color: #2D2D2D; 170 | color: #D4D4D4; 171 | border: 1px solid #404040; 172 | padding: 5px; 173 | border-radius: 5px; 174 | min-width: 64px; 175 | } 176 | 177 | QComboBox::drop-down { 178 | background-color: #2D2D2D; 179 | color: #D4D4D4; 180 | border: none; 181 | } 182 | 183 | QComboBox::down-arrow { 184 | image: url(./icons/drop_down_arrow.svg); 185 | color: #D4D4D4; 186 | width: 24px; 187 | height: 24px; 188 | } 189 | 190 | QComboBox QAbstractItemView { 191 | background-color: #2D2D2D; 192 | color: #D4D4D4; 193 | border: 1px solid #404040; 194 | selection-background-color: #404040; 195 | selection-color: #FFFFFF; 196 | font-size: 12px; 197 | outline: none; 198 | } 199 | 200 | QComboBox QAbstractItemView::item { 201 | padding: 5px; 202 | min-height: 24px; 203 | } 204 | 205 | QComboBox QAbstractItemView::item:hover { 206 | background-color: #333333; 207 | } 208 | 209 | #cancelButton { 210 | background-color: #2D2D2D; 211 | } 212 | 213 | #cancelButton:hover { 214 | background-color: #333333; 215 | } 216 | 217 | QListWidget#modelList { 218 | background-color: #2D2D2D; 219 | border: 1px solid #404040; 220 | border-radius: 5px; 221 | color: #D4D4D4; 222 | padding: 5px; 223 | font-size: 14px; 224 | } 225 | 226 | QListWidget#modelList::item:selected { 227 | background-color: #404040; 228 | color: #FFFFFF; 229 | } 230 | 231 | QLabel#AppLabel { 232 | color: #D4D4D4; 233 | font-size: 18px; 234 | font-weight: bold; 235 | } 236 | 237 | QListWidget#suggestionList { 238 | background-color: #2D2D2D; 239 | border: 1px solid #404040; 240 | border-radius: 5px; 241 | color: #D4D4D4; 242 | padding: 5px; 243 | } 244 | 245 | QListWidget#suggestionList::item { 246 | padding: 0px; 247 | } 248 | 249 | QListWidget#suggestionList::item:hover { 250 | background-color: #333333; 251 | } 252 | 253 | QTextEdit#inputField { 254 | background-color: #2D2D2D; 255 | border: 1px solid #404040; 256 | padding: 10px; 257 | font-size: 14px; 258 | color: #D4D4D4; 259 | border-radius: 10px; 260 | } 261 | 262 | #modelCameraButton { 263 | background: transparent; 264 | border: none; 265 | padding: 2px; 266 | border-radius: 4px; 267 | } 268 | 269 | #modelCameraButton:hover { 270 | background: rgba(255, 255, 255, 0.1); 271 | } 272 | 273 | #modelCameraButton:pressed { 274 | background: rgba(255, 255, 255, 0.2); 275 | } 276 | 277 | QWidget#promptItem { 278 | background-color: transparent; 279 | } 280 | 281 | QWidget#promptTextContainer { 282 | background-color: #2D2D2D; 283 | border-radius: 5px; 284 | border: 1px solid #404040; 285 | } 286 | 287 | QTextEdit#promptText { 288 | background-color: transparent; 289 | border: none; 290 | padding: 10px; 291 | font-size: 14px; 292 | color: #D4D4D4; 293 | } 294 | 295 | QPushButton#promptSelectButton { 296 | background-color: #333333; 297 | color: #D4D4D4; 298 | border: 1px solid #404040; 299 | border-radius: 4px; 300 | } 301 | 302 | QPushButton#promptSelectButton:hover { 303 | background-color: #4CAF50; 304 | color: #FFFFFF; 305 | } 306 | 307 | QPushButton#promptDeleteButton { 308 | background-color: #333333; 309 | color: #D4D4D4; 310 | border: 1px solid #404040; 311 | border-radius: 4px; 312 | } 313 | 314 | QPushButton#promptDeleteButton:hover { 315 | background-color: #F44336; 316 | color: #FFFFFF; 317 | } 318 | 319 | /* Tray Menu Styling */ 320 | QMenu#trayMenu { 321 | background-color: #2D2D2D; 322 | border: 1px solid #404040; 323 | border-radius: 4px; 324 | padding: 4px 0px; 325 | } 326 | 327 | QMenu#trayMenu::item { 328 | color: #D4D4D4; 329 | padding: 4px 8px; 330 | font-size: 14px; 331 | margin: 0px; 332 | } 333 | 334 | QMenu#trayMenu::item:selected { 335 | background-color: #404040; 336 | color: #FFFFFF; 337 | } 338 | 339 | QMenu#trayMenu::separator { 340 | height: 1px; 341 | background: #404040; 342 | margin: 4px 0px; 343 | } 344 | 345 | /* Thumbnail delete button styling */ 346 | QPushButton#deleteThumbnailButton { 347 | background-color: #2D2D2D; 348 | border: 1px solid #404040; 349 | border-radius: 8px; 350 | margin: 0; 351 | padding: 0; 352 | } 353 | 354 | QPushButton#deleteThumbnailButton:hover { 355 | background-color: #F44336; 356 | } 357 | 358 | /* Version label styling */ 359 | QLabel#VersionLabel { 360 | color: #888888; 361 | font-size: 10px; 362 | padding: 0; 363 | margin: 0; 364 | } 365 | 366 | #modelDefaultButton { 367 | background-color: transparent; 368 | border: none; 369 | padding: 2px; 370 | border-radius: 4px; 371 | } 372 | 373 | #modelDefaultButton:hover { 374 | background-color: #404040; 375 | } 376 | 377 | #modelDefaultButton[selected="true"] { 378 | background-color: #7289DA; 379 | } 380 | 381 | #modelDefaultButton[selected="true"]:hover { 382 | background-color: #8599E1; 383 | } 384 | 385 | QPushButton#settingsButton { 386 | --icon-color: #D4D4D4; 387 | } 388 | 389 | QPushButton#clearButton { 390 | --icon-color: #D4D4D4; 391 | } 392 | 393 | QPushButton#verticalButton { 394 | --icon-color: #D4D4D4; 395 | } 396 | 397 | QPushButton#closeButton { 398 | --icon-color: #D4D4D4; 399 | } 400 | 401 | QPushButton#sendButton { 402 | --icon-color: #D4D4D4; 403 | } 404 | 405 | QPushButton#screenshotButton { 406 | --icon-color: #D4D4D4; 407 | } 408 | 409 | QPushButton#systemPromptButton { 410 | --icon-color: #D4D4D4; 411 | } 412 | -------------------------------------------------------------------------------- /themes/ice.qss: -------------------------------------------------------------------------------- 1 | QWidget#mainWidget { 2 | background-color: #EBEBEB; 3 | border-radius: 20px; 4 | border: 1px solid #D6D6D6; 5 | } 6 | 7 | QWidget#outerWidget { 8 | background-color: #EBEBEB; 9 | border-radius: 20px; 10 | } 11 | 12 | QWidget#chatMessage { 13 | background-color: #E1E1E1; 14 | color: #283848; 15 | } 16 | 17 | QWidget#chatMessageUser { 18 | border-color: #3498DB; 19 | } 20 | 21 | QWidget#chatMessageAssistant { 22 | background-color: #CBCBCB; 23 | border-color: #24699A; 24 | } 25 | 26 | QScrollBar:vertical { 27 | border: none; 28 | background: transparent; 29 | width: 8px; 30 | margin: 0px 2px 0px 2px; 31 | } 32 | 33 | QScrollBar::handle:vertical { 34 | background: #E0E0E0; 35 | border-radius: 4px; 36 | min-height: 20px; 37 | } 38 | 39 | QScrollBar::handle:vertical:hover { 40 | background: #D0D0D0; 41 | } 42 | 43 | QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { 44 | height: 0px; 45 | } 46 | 47 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 48 | background: none; 49 | } 50 | 51 | QScrollBar:horizontal { 52 | border: none; 53 | background: transparent; 54 | height: 8px; 55 | margin: 2px 0px 2px 0px; 56 | } 57 | 58 | QScrollBar::handle:horizontal { 59 | background: #E0E0E0; 60 | border-radius: 4px; 61 | min-width: 20px; 62 | } 63 | 64 | QScrollBar::handle:horizontal:hover { 65 | background: #D0D0D0; 66 | } 67 | 68 | QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { 69 | width: 0px; 70 | } 71 | 72 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { 73 | background: none; 74 | } 75 | 76 | QWidget#provider_settingsPanel { 77 | background-color: #EBEBEB; 78 | border: 1px solid #E0E0E0; 79 | padding: 10px; 80 | font-size: 14px; 81 | color: #2C3E50; 82 | border-radius: 10px; 83 | } 84 | 85 | QWidget#systemPromptButtonContainer { 86 | background-color: #EBEBEB; 87 | border: 1px solid #E0E0E0; 88 | padding: 10px; 89 | font-size: 14px; 90 | color: #2C3E50; 91 | border-radius: 10px; 92 | } 93 | 94 | QScrollArea { 95 | background-color: #EBEBEB; 96 | border: none; 97 | } 98 | 99 | QWidget#scrollLayout { 100 | background-color: #EBEBEB; 101 | border: 1px solid #E0E0E0; 102 | padding: 10px; 103 | font-size: 14px; 104 | color: #2C3E50; 105 | border-radius: 10px; 106 | } 107 | 108 | QLabel { 109 | color: #2C3E50; 110 | font-size: 14px; 111 | } 112 | 113 | QLineEdit { 114 | background-color: #EBEBEB; 115 | border: 1px solid #E0E0E0; 116 | padding: 5px; 117 | font-size: 12px; 118 | color: #2C3E50; 119 | border-radius: 5px; 120 | } 121 | 122 | QTextEdit { 123 | background-color: #EBEBEB; 124 | border: 1px solid #E0E0E0; 125 | padding: 10px; 126 | font-size: 14px; 127 | color: #2C3E50; 128 | border-radius: 5px; 129 | } 130 | 131 | QTextBrowser { 132 | background-color: #EBEBEB; 133 | border: 1px solid #E0E0E0; 134 | padding: 10px; 135 | font-size: 14px; 136 | color: #2C3E50; 137 | border-radius: 10px; 138 | } 139 | 140 | QPushButton { 141 | background-color: #3498DB; 142 | color: #EBEBEB; 143 | border: none; 144 | padding: 10px 20px; 145 | border-radius: 4px; 146 | font-size: 14px; 147 | font-weight: bold; 148 | } 149 | 150 | QPushButton:hover { 151 | background-color: #2980B9; 152 | } 153 | 154 | QPushButton#verticalButton { 155 | background-color: #EBEBEB; 156 | margin-left: 2px; 157 | border: 2px solid #E0E0E0; 158 | border-top-left-radius: 0px; 159 | border-bottom-left-radius: 0px; 160 | border-top-right-radius: 10px; 161 | border-bottom-right-radius: 10px; 162 | } 163 | 164 | QPushButton#verticalButton:hover { 165 | background-color: #F5F5F5; 166 | } 167 | 168 | QComboBox { 169 | background-color: #EBEBEB; 170 | color: #2C3E50; 171 | border: 1px solid #E0E0E0; 172 | padding: 5px; 173 | border-radius: 5px; 174 | min-width: 64px; 175 | } 176 | 177 | QComboBox::drop-down { 178 | background-color: #EBEBEB; 179 | color: #2C3E50; 180 | border: none; 181 | } 182 | 183 | QComboBox::down-arrow { 184 | image: url(./icons/drop_down_arrow.svg); 185 | color: #E0E0E0; 186 | width: 24px; 187 | height: 24px; 188 | } 189 | 190 | QComboBox QAbstractItemView { 191 | background-color: #EBEBEB; 192 | color: #2C3E50; 193 | border: 1px solid #E0E0E0; 194 | selection-background-color: #F5F5F5; 195 | selection-color: #2C3E50; 196 | font-size: 12px; 197 | outline: none; 198 | } 199 | 200 | QComboBox QAbstractItemView::item { 201 | padding: 5px; 202 | min-height: 24px; 203 | } 204 | 205 | QComboBox QAbstractItemView::item:hover { 206 | background-color: #F5F5F5; 207 | } 208 | 209 | #cancelButton { 210 | background-color: #E0E0E0; 211 | color: #2C3E50; 212 | } 213 | 214 | #cancelButton:hover { 215 | background-color: #D0D0D0; 216 | } 217 | 218 | QListWidget#modelList { 219 | background-color: #EBEBEB; 220 | border: 1px solid #E0E0E0; 221 | border-radius: 5px; 222 | color: #2C3E50; 223 | padding: 5px; 224 | font-size: 14px; 225 | } 226 | 227 | QListWidget#modelList::item:selected { 228 | background-color: #F5F5F5; 229 | color: #2C3E50; 230 | } 231 | 232 | QLabel#AppLabel { 233 | color: #2C3E50; 234 | font-size: 18px; 235 | font-weight: bold; 236 | } 237 | 238 | QListWidget#suggestionList { 239 | background-color: #EBEBEB; 240 | border: 1px solid #E0E0E0; 241 | border-radius: 5px; 242 | color: #2C3E50; 243 | padding: 5px; 244 | } 245 | 246 | QListWidget#suggestionList::item { 247 | padding: 0px; 248 | } 249 | 250 | QListWidget#suggestionList::item:hover { 251 | background-color: #F5F5F5; 252 | } 253 | 254 | QTextEdit#inputField { 255 | background-color: #EBEBEB; 256 | border: 1px solid #E0E0E0; 257 | padding: 10px; 258 | font-size: 14px; 259 | color: #2C3E50; 260 | border-radius: 10px; 261 | } 262 | 263 | #modelCameraButton { 264 | background: transparent; 265 | border: none; 266 | padding: 2px; 267 | border-radius: 4px; 268 | } 269 | 270 | #modelCameraButton:hover { 271 | background: rgba(52, 152, 219, 0.1); 272 | } 273 | 274 | #modelCameraButton:pressed { 275 | background: rgba(52, 152, 219, 0.2); 276 | } 277 | 278 | QWidget#promptItem { 279 | background-color: transparent; 280 | } 281 | 282 | QWidget#promptTextContainer { 283 | background-color: #EBEBEB; 284 | border-radius: 5px; 285 | border: 1px solid #E0E0E0; 286 | } 287 | 288 | QTextEdit#promptText { 289 | background-color: transparent; 290 | border: none; 291 | padding: 10px; 292 | font-size: 14px; 293 | color: #2C3E50; 294 | } 295 | 296 | QPushButton#promptSelectButton { 297 | background-color: #2ECC71; 298 | color: #EBEBEB; 299 | border: none; 300 | border-radius: 4px; 301 | } 302 | 303 | QPushButton#promptSelectButton:hover { 304 | background-color: #27AE60; 305 | } 306 | 307 | QPushButton#promptDeleteButton { 308 | background-color: #E74C3C; 309 | color: #EBEBEB; 310 | border: none; 311 | border-radius: 4px; 312 | } 313 | 314 | QPushButton#promptDeleteButton:hover { 315 | background-color: #C0392B; 316 | } 317 | 318 | /* Tray Menu Styling */ 319 | QMenu#trayMenu { 320 | background-color: #EBEBEB; 321 | border: 1px solid #E0E0E0; 322 | border-radius: 4px; 323 | padding: 4px 0px; 324 | } 325 | 326 | QMenu#trayMenu::item { 327 | color: #2C3E50; 328 | padding: 4px 8px; 329 | font-size: 14px; 330 | margin: 0px; 331 | } 332 | 333 | QMenu#trayMenu::item:selected { 334 | background-color: #F5F5F5; 335 | color: #2C3E50; 336 | } 337 | 338 | QMenu#trayMenu::separator { 339 | height: 1px; 340 | background: #E0E0E0; 341 | margin: 4px 0px; 342 | } 343 | 344 | /* Thumbnail delete button styling */ 345 | QPushButton#deleteThumbnailButton { 346 | background-color: #EBEBEB; 347 | border: 1px solid #E0E0E0; 348 | border-radius: 8px; 349 | margin: 0; 350 | padding: 0; 351 | } 352 | 353 | QPushButton#deleteThumbnailButton:hover { 354 | background-color: #E74C3C; 355 | } 356 | 357 | /* Version label styling */ 358 | QLabel#VersionLabel { 359 | color: #95A5A6; 360 | font-size: 10px; 361 | padding: 0; 362 | margin: 0; 363 | } 364 | 365 | #modelDefaultButton { 366 | background-color: transparent; 367 | border: none; 368 | padding: 2px; 369 | border-radius: 4px; 370 | } 371 | 372 | #modelDefaultButton:hover { 373 | background-color: #4AA3E0; 374 | } 375 | 376 | #modelDefaultButton[selected="true"] { 377 | background-color: #3498DB; 378 | } 379 | 380 | #modelDefaultButton[selected="true"]:hover { 381 | background-color: #4AA3E0; 382 | } 383 | 384 | QPushButton#settingsButton { 385 | --icon-color: #2C3E50; 386 | } 387 | 388 | QPushButton#clearButton { 389 | --icon-color: #2C3E50; 390 | } 391 | 392 | QPushButton#verticalButton { 393 | --icon-color: #2C3E50; 394 | } 395 | 396 | QPushButton#closeButton { 397 | --icon-color: #2C3E50; 398 | } 399 | 400 | QPushButton#sendButton { 401 | --icon-color: #EBEBEB; 402 | } 403 | 404 | QPushButton#screenshotButton { 405 | --icon-color: #2C3E50; 406 | } 407 | 408 | QPushButton#systemPromptButton { 409 | --icon-color: #2C3E50; 410 | } 411 | -------------------------------------------------------------------------------- /themes/light.qss: -------------------------------------------------------------------------------- 1 | QWidget#mainWidget { 2 | background-color: #FFFFFF; 3 | border-radius: 20px; 4 | border: 1px solid #CCCCCC; 5 | } 6 | 7 | QWidget#outerWidget { 8 | background-color: #E0E0E0; 9 | border-radius: 20px; 10 | } 11 | 12 | QWidget#chatMessage { 13 | background-color: #F1F1F1; 14 | color: #1E1E1E; 15 | } 16 | 17 | QWidget#chatMessageUser { 18 | border-color: #808080; 19 | } 20 | 21 | QWidget#chatMessageAssistant { 22 | background-color: #E1E1E1; 23 | border-color: #808080; 24 | } 25 | 26 | QScrollBar:vertical { 27 | border: none; 28 | background: transparent; 29 | width: 8px; 30 | margin: 0px 2px 0px 2px; 31 | } 32 | 33 | QScrollBar::handle:vertical { 34 | background: #CCCCCC; 35 | border-radius: 4px; 36 | min-height: 20px; 37 | } 38 | 39 | QScrollBar::handle:vertical:hover { 40 | background: #BBBBBB; 41 | } 42 | 43 | QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { 44 | height: 0px; 45 | } 46 | 47 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 48 | background: none; 49 | } 50 | 51 | QScrollBar:horizontal { 52 | border: none; 53 | background: transparent; 54 | height: 8px; 55 | margin: 2px 0px 2px 0px; 56 | } 57 | 58 | QScrollBar::handle:horizontal { 59 | background: #CCCCCC; 60 | border-radius: 4px; 61 | min-width: 20px; 62 | } 63 | 64 | QScrollBar::handle:horizontal:hover { 65 | background: #BBBBBB; 66 | } 67 | 68 | QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { 69 | width: 0px; 70 | } 71 | 72 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { 73 | background: none; 74 | } 75 | 76 | QWidget#provider_settingsPanel { 77 | background-color: rgb(245, 245, 245); 78 | border: 1px solid #CCCCCC; 79 | padding: 10px; 80 | font-size: 14px; 81 | color: #333333; 82 | border-radius: 10px; 83 | } 84 | 85 | QWidget#systemPromptButtonContainer { 86 | background-color: rgb(245, 245, 245); 87 | border: 1px solid #CCCCCC; 88 | padding: 10px; 89 | font-size: 14px; 90 | color: #333333; 91 | border-radius: 10px; 92 | } 93 | 94 | QScrollArea { 95 | background-color: #FFFFFF; 96 | border: none; 97 | } 98 | 99 | QWidget#scrollLayout { 100 | background-color: rgb(245, 245, 245); 101 | border: 1px solid #CCCCCC; 102 | padding: 10px; 103 | font-size: 14px; 104 | color: #333333; 105 | border-radius: 10px; 106 | } 107 | 108 | QLabel { 109 | color: #333333; 110 | font-size: 14px; 111 | } 112 | 113 | QLineEdit { 114 | background-color: #FFFFFF; 115 | border: 1px solid #CCCCCC; 116 | padding: 5px; 117 | font-size: 12px; 118 | color: #333333; 119 | border-radius: 5px; 120 | } 121 | 122 | QTextEdit { 123 | background-color: #FFFFFF; 124 | border: 1px solid #CCCCCC; 125 | padding: 10px; 126 | font-size: 14px; 127 | color: #333333; 128 | border-radius: 5px; 129 | } 130 | 131 | QTextBrowser { 132 | background-color: #FFFFFF; 133 | border: 1px solid #CCCCCC; 134 | padding: 10px; 135 | font-size: 14px; 136 | color: #333333; 137 | border-radius: 10px; 138 | } 139 | 140 | QPushButton { 141 | background-color: #F0F0F0; 142 | color: #333333; 143 | border: 1px solid #CCCCCC; 144 | padding: 10px 20px; 145 | border-radius: 4px; 146 | font-size: 14px; 147 | font-weight: bold; 148 | } 149 | 150 | QPushButton:hover { 151 | background-color: #E0E0E0; 152 | } 153 | 154 | QPushButton#verticalButton { 155 | background-color: #FFFFFF; 156 | margin-left: 2px; 157 | border: 2px solid #CCCCCC; 158 | border-top-left-radius: 0px; 159 | border-bottom-left-radius: 0px; 160 | border-top-right-radius: 10px; 161 | border-bottom-right-radius: 10px; 162 | } 163 | 164 | QPushButton#verticalButton:hover { 165 | background-color: #F0F0F0; 166 | } 167 | 168 | QComboBox { 169 | background-color: #FFFFFF; 170 | color: #333333; 171 | border: 1px solid #CCCCCC; 172 | padding: 5px; 173 | border-radius: 5px; 174 | min-width: 64px; 175 | } 176 | 177 | QComboBox::drop-down { 178 | background-color: #FFFFFF; 179 | color: #333333; 180 | border: none; 181 | } 182 | 183 | QComboBox::down-arrow { 184 | image: url(./icons/drop_down_arrow.svg); 185 | color: #FF0000; 186 | width: 24px; 187 | height: 24px; 188 | } 189 | 190 | QComboBox QAbstractItemView { 191 | background-color: #FFFFFF; 192 | color: #333333; 193 | border: 1px solid #CCCCCC; 194 | selection-background-color: #E0E0E0; 195 | selection-color: #333333; 196 | font-size: 12px; 197 | outline: none; 198 | } 199 | 200 | QComboBox QAbstractItemView::item { 201 | padding: 5px; 202 | min-height: 24px; 203 | } 204 | 205 | QComboBox QAbstractItemView::item:hover { 206 | background-color: #F0F0F0; 207 | } 208 | 209 | #cancelButton { 210 | background-color: #F8F8F8; 211 | } 212 | 213 | #cancelButton:hover { 214 | background-color: #EFEFEF; 215 | } 216 | 217 | QListWidget#modelList { 218 | background-color: #FFFFFF; 219 | border: 1px solid #CCCCCC; 220 | border-radius: 5px; 221 | color: #333333; 222 | padding: 5px; 223 | font-size: 14px; 224 | } 225 | 226 | QListWidget#modelList::item:selected { 227 | background-color: #D0D0D0; 228 | color: #000000; 229 | } 230 | 231 | QLabel#AppLabel { 232 | color: #333333; 233 | font-size: 18px; 234 | font-weight: bold; 235 | } 236 | 237 | QListWidget#suggestionList { 238 | background-color: #FFFFFF; 239 | border: 1px solid #CCCCCC; 240 | border-radius: 5px; 241 | color: #333333; 242 | padding: 5px; 243 | } 244 | 245 | QListWidget#suggestionList::item { 246 | padding: 0px; 247 | } 248 | 249 | 250 | QListWidget#suggestionList::item:hover { 251 | background-color: #E0E0E0; 252 | } 253 | 254 | QTextEdit#inputField { 255 | background-color: #FFFFFF; 256 | border: 1px solid #CCCCCC; 257 | padding: 10px; 258 | font-size: 14px; 259 | color: #333333; 260 | border-radius: 10px; 261 | } 262 | 263 | #modelCameraButton { 264 | background: transparent; 265 | border: none; 266 | padding: 2px; 267 | border-radius: 4px; 268 | } 269 | 270 | #modelCameraButton:hover { 271 | background: rgba(160, 160, 160, 0.1); 272 | } 273 | 274 | #modelCameraButton:pressed { 275 | background: rgba(160, 160, 160, 0.2); 276 | } 277 | 278 | QWidget#promptItem { 279 | background-color: transparent; 280 | } 281 | 282 | QWidget#promptTextContainer { 283 | background-color: #FFFFFF; 284 | border-radius: 5px; 285 | border: 1px solid #CCCCCC; 286 | } 287 | 288 | QTextEdit#promptText { 289 | background-color: transparent; 290 | border: none; 291 | padding: 10px; 292 | font-size: 14px; 293 | color: #333333; 294 | } 295 | 296 | QPushButton#promptSelectButton { 297 | background-color: #F0F0F0; 298 | color: #333333; 299 | border: 1px solid #CCCCCC; 300 | border-radius: 4px; 301 | } 302 | 303 | QPushButton#promptSelectButton:hover { 304 | background-color: #A0D995; 305 | color: #FFFFFF; 306 | } 307 | 308 | QPushButton#promptDeleteButton { 309 | background-color: #F0F0F0; 310 | color: #333333; 311 | border: 1px solid #CCCCCC; 312 | border-radius: 4px; 313 | } 314 | 315 | QPushButton#promptDeleteButton:hover { 316 | background-color: #FF6961; 317 | color: #FFFFFF; 318 | } 319 | 320 | /* Tray Menu Styling */ 321 | QMenu#trayMenu { 322 | background-color: #FFFFFF; 323 | border: 1px solid #CCCCCC; 324 | border-radius: 4px; 325 | padding: 4px 0px; 326 | } 327 | 328 | QMenu#trayMenu::item { 329 | color: #333333; 330 | padding: 4px 8px; 331 | font-size: 14px; 332 | margin: 0px; 333 | } 334 | 335 | QMenu#trayMenu::item:selected { 336 | background-color: #A0C4FF; 337 | color: #000000; 338 | } 339 | 340 | QMenu#trayMenu::separator { 341 | height: 1px; 342 | background: #CCCCCC; 343 | margin: 4px 0px; 344 | } 345 | 346 | /* Thumbnail delete button styling */ 347 | QPushButton#deleteThumbnailButton { 348 | background-color: #FFFFFF; 349 | border: 1px solid #CCCCCC; 350 | border-radius: 8px; 351 | margin: 0; 352 | padding: 0; 353 | } 354 | QPushButton#deleteThumbnailButton:hover { 355 | background-color: #FF6961; 356 | } 357 | 358 | /* Version label styling */ 359 | QLabel#VersionLabel { 360 | color: #777777; 361 | font-size: 10px; 362 | padding: 0; 363 | margin: 0; 364 | } 365 | 366 | #modelDefaultButton { 367 | background-color: transparent; 368 | border: none; 369 | padding: 2px; 370 | border-radius: 4px; 371 | } 372 | 373 | #modelDefaultButton:hover { 374 | background-color: #A0C4FF; 375 | } 376 | 377 | #modelDefaultButton[selected="true"] { 378 | background-color: #A0C4FF; 379 | } 380 | 381 | #modelDefaultButton[selected="true"]:hover { 382 | background-color: #BFD7FF; 383 | } 384 | 385 | QPushButton#settingsButton { 386 | --icon-color: #1E1E1E; 387 | } 388 | 389 | QPushButton#clearButton { 390 | --icon-color: #1E1E1E; 391 | } 392 | 393 | QPushButton#verticalButton { 394 | --icon-color: #1E1E1E; 395 | } 396 | 397 | QPushButton#closeButton { 398 | --icon-color: #1E1E1E; 399 | } 400 | 401 | QPushButton#sendButton { 402 | --icon-color: #1E1E1E; 403 | } 404 | 405 | QPushButton#screenshotButton { 406 | --icon-color: #1E1E1E; 407 | } 408 | 409 | QPushButton#systemPromptButton { 410 | --icon-color: #1E1E1E; 411 | } 412 | -------------------------------------------------------------------------------- /themes/synthwave.qss: -------------------------------------------------------------------------------- 1 | QWidget#mainWidget { 2 | background-color: #2b213a; 3 | border-radius: 20px; 4 | border: 1px solid #453a5c; 5 | } 6 | 7 | QWidget#outerWidget { 8 | background-color: #1a1525; 9 | border-radius: 20px; 10 | } 11 | 12 | QWidget#chatMessage { 13 | background-color: #2b213a; 14 | color: #ff71ce; 15 | } 16 | 17 | QWidget#chatMessageUser { 18 | border-color: #453a5c; 19 | } 20 | 21 | QWidget#chatMessageAssistant { 22 | background-color: #1a1525; 23 | border-color: #453a5c; 24 | } 25 | 26 | QScrollBar:vertical { 27 | border: none; 28 | background: transparent; 29 | width: 8px; 30 | margin: 0px 2px 0px 2px; 31 | } 32 | 33 | QScrollBar::handle:vertical { 34 | background: #453a5c; 35 | border-radius: 4px; 36 | min-height: 20px; 37 | } 38 | 39 | QScrollBar::handle:vertical:hover { 40 | background: #574b73; 41 | } 42 | 43 | QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical { 44 | height: 0px; 45 | } 46 | 47 | QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical { 48 | background: none; 49 | } 50 | 51 | QScrollBar:horizontal { 52 | border: none; 53 | background: transparent; 54 | height: 8px; 55 | margin: 2px 0px 2px 0px; 56 | } 57 | 58 | QScrollBar::handle:horizontal { 59 | background: #453a5c; 60 | border-radius: 4px; 61 | min-width: 20px; 62 | } 63 | 64 | QScrollBar::handle:horizontal:hover { 65 | background: #574b73; 66 | } 67 | 68 | QScrollBar::add-line:horizontal, QScrollBar::sub-line:horizontal { 69 | width: 0px; 70 | } 71 | 72 | QScrollBar::add-page:horizontal, QScrollBar::sub-page:horizontal { 73 | background: none; 74 | } 75 | 76 | QWidget#provider_settingsPanel { 77 | background-color: #2b213a; 78 | border: 1px solid #453a5c; 79 | padding: 10px; 80 | font-size: 14px; 81 | color: #ff71ce; 82 | border-radius: 10px; 83 | } 84 | 85 | QWidget#systemPromptButtonContainer { 86 | background-color: #2b213a; 87 | border: 1px solid #453a5c; 88 | padding: 10px; 89 | font-size: 14px; 90 | color: #ff71ce; 91 | border-radius: 10px; 92 | } 93 | 94 | QScrollArea { 95 | background-color: #2b213a; 96 | border: none; 97 | } 98 | 99 | QWidget#scrollLayout { 100 | background-color: #2b213a; 101 | border: 1px solid #453a5c; 102 | padding: 10px; 103 | font-size: 14px; 104 | color: #ff71ce; 105 | border-radius: 10px; 106 | } 107 | 108 | QLabel { 109 | color: #ff71ce; 110 | font-size: 14px; 111 | } 112 | 113 | QLineEdit { 114 | background-color: #1a1525; 115 | border: 1px solid #453a5c; 116 | padding: 5px; 117 | font-size: 12px; 118 | color: #ff71ce; 119 | border-radius: 5px; 120 | } 121 | 122 | QTextEdit { 123 | background-color: #1a1525; 124 | border: 1px solid #453a5c; 125 | padding: 10px; 126 | font-size: 14px; 127 | color: #ff71ce; 128 | border-radius: 5px; 129 | } 130 | 131 | QTextBrowser { 132 | background-color: #1a1525; 133 | border: 1px solid #453a5c; 134 | padding: 10px; 135 | font-size: 14px; 136 | color: #ff71ce; 137 | border-radius: 10px; 138 | } 139 | 140 | QPushButton { 141 | background-color: #453a5c; 142 | color: #ff71ce; 143 | border: 1px solid #574b73; 144 | padding: 10px 20px; 145 | border-radius: 4px; 146 | font-size: 14px; 147 | font-weight: bold; 148 | } 149 | 150 | QPushButton:hover { 151 | background-color: #574b73; 152 | } 153 | 154 | QPushButton#verticalButton { 155 | background-color: #2b213a; 156 | margin-left: 2px; 157 | border: 2px solid #453a5c; 158 | border-top-left-radius: 0px; 159 | border-bottom-left-radius: 0px; 160 | border-top-right-radius: 10px; 161 | border-bottom-right-radius: 10px; 162 | } 163 | 164 | QPushButton#verticalButton:hover { 165 | background-color: #453a5c; 166 | } 167 | 168 | QComboBox { 169 | background-color: #1a1525; 170 | color: #ff71ce; 171 | border: 1px solid #453a5c; 172 | padding: 5px; 173 | border-radius: 5px; 174 | min-width: 64px; 175 | } 176 | 177 | QComboBox::drop-down { 178 | background-color: #1a1525; 179 | color: #ff71ce; 180 | border: none; 181 | } 182 | 183 | QComboBox::down-arrow { 184 | image: url(./icons/drop_down_arrow.svg); 185 | width: 24px; 186 | height: 24px; 187 | color: #ff71ce; 188 | } 189 | 190 | QComboBox QAbstractItemView { 191 | background-color: #1a1525; 192 | color: #ff71ce; 193 | border: 1px solid #453a5c; 194 | selection-background-color: #453a5c; 195 | selection-color: #ff71ce; 196 | font-size: 12px; 197 | outline: none; 198 | } 199 | 200 | QComboBox QAbstractItemView::item { 201 | padding: 5px; 202 | min-height: 24px; 203 | } 204 | 205 | QComboBox QAbstractItemView::item:hover { 206 | background-color: #453a5c; 207 | } 208 | 209 | #cancelButton { 210 | background-color: #2b213a; 211 | } 212 | 213 | #cancelButton:hover { 214 | background-color: #453a5c; 215 | } 216 | 217 | QListWidget#modelList { 218 | background-color: #1a1525; 219 | border: 1px solid #453a5c; 220 | border-radius: 5px; 221 | color: #ff71ce; 222 | padding: 5px; 223 | font-size: 14px; 224 | } 225 | 226 | QListWidget#modelList::item:selected { 227 | background-color: #453a5c; 228 | color: #ff71ce; 229 | } 230 | 231 | QLabel#AppLabel { 232 | color: #ff71ce; 233 | font-size: 18px; 234 | font-weight: bold; 235 | } 236 | 237 | QListWidget#suggestionList { 238 | background-color: #1a1525; 239 | border: 1px solid #453a5c; 240 | border-radius: 5px; 241 | color: #ff71ce; 242 | padding: 5px; 243 | } 244 | 245 | QListWidget#suggestionList::item { 246 | padding: 0px; 247 | } 248 | 249 | QListWidget#suggestionList::item:hover { 250 | background-color: #453a5c; 251 | } 252 | 253 | QTextEdit#inputField { 254 | background-color: #1a1525; 255 | border: 1px solid #453a5c; 256 | padding: 10px; 257 | font-size: 14px; 258 | color: #ff71ce; 259 | border-radius: 10px; 260 | } 261 | 262 | #modelCameraButton { 263 | background: transparent; 264 | border: none; 265 | padding: 2px; 266 | border-radius: 4px; 267 | } 268 | 269 | #modelCameraButton:hover { 270 | background: rgba(69, 58, 92, 0.5); 271 | } 272 | 273 | #modelCameraButton:pressed { 274 | background: rgba(69, 58, 92, 0.7); 275 | } 276 | 277 | QWidget#promptItem { 278 | background-color: transparent; 279 | } 280 | 281 | QWidget#promptTextContainer { 282 | background-color: #1a1525; 283 | border-radius: 5px; 284 | border: 1px solid #453a5c; 285 | } 286 | 287 | QTextEdit#promptText { 288 | background-color: transparent; 289 | border: none; 290 | padding: 10px; 291 | font-size: 14px; 292 | color: #ff71ce; 293 | } 294 | 295 | QPushButton#promptSelectButton { 296 | background-color: #453a5c; 297 | color: #ff71ce; 298 | border: 1px solid #574b73; 299 | border-radius: 4px; 300 | } 301 | 302 | QPushButton#promptSelectButton:hover { 303 | background-color: #01cdfe; 304 | color: #1a1525; 305 | } 306 | 307 | QPushButton#promptDeleteButton { 308 | background-color: #453a5c; 309 | color: #ff71ce; 310 | border: 1px solid #574b73; 311 | border-radius: 4px; 312 | } 313 | 314 | QPushButton#promptDeleteButton:hover { 315 | background-color: #fe4a49; 316 | color: #1a1525; 317 | } 318 | 319 | /* Tray Menu Styling */ 320 | QMenu#trayMenu { 321 | background-color: #1a1525; 322 | border: 1px solid #453a5c; 323 | border-radius: 4px; 324 | padding: 4px 0px; 325 | } 326 | 327 | QMenu#trayMenu::item { 328 | color: #ff71ce; 329 | padding: 4px 8px; 330 | font-size: 14px; 331 | margin: 0px; 332 | } 333 | 334 | QMenu#trayMenu::item:selected { 335 | background-color: #453a5c; 336 | color: #ff71ce; 337 | } 338 | 339 | QMenu#trayMenu::separator { 340 | height: 1px; 341 | background: #453a5c; 342 | margin: 4px 0px; 343 | } 344 | 345 | /* Thumbnail delete button styling */ 346 | QPushButton#deleteThumbnailButton { 347 | background-color: #2b213a; 348 | border: 1px solid #453a5c; 349 | border-radius: 8px; 350 | margin: 0; 351 | padding: 0; 352 | } 353 | 354 | QPushButton#deleteThumbnailButton:hover { 355 | background-color: #fe4a49; 356 | } 357 | 358 | /* Version label styling */ 359 | QLabel#VersionLabel { 360 | color: #b967ff; 361 | font-size: 10px; 362 | padding: 0; 363 | margin: 0; 364 | } 365 | 366 | #modelDefaultButton { 367 | background-color: transparent; 368 | border: none; 369 | padding: 2px; 370 | border-radius: 4px; 371 | } 372 | 373 | #modelDefaultButton:hover { 374 | background-color: #453a5c; 375 | } 376 | 377 | #modelDefaultButton[selected="true"] { 378 | background-color: #b967ff; 379 | } 380 | 381 | #modelDefaultButton[selected="true"]:hover { 382 | background-color: #ff71ce; 383 | } 384 | 385 | QPushButton#settingsButton { 386 | --icon-color: #ff71ce; 387 | } 388 | 389 | QPushButton#clearButton { 390 | --icon-color: #ff71ce; 391 | } 392 | 393 | QPushButton#verticalButton { 394 | --icon-color: #ff71ce; 395 | } 396 | 397 | QPushButton#closeButton { 398 | --icon-color: #ff71ce; 399 | } 400 | 401 | QPushButton#sendButton { 402 | --icon-color: #ff71ce; 403 | } 404 | 405 | QPushButton#screenshotButton { 406 | --icon-color: #ff71ce; 407 | } 408 | 409 | QPushButton#systemPromptButton { 410 | --icon-color: #ff71ce; 411 | } 412 | -------------------------------------------------------------------------------- /utils/chat_storage.py: -------------------------------------------------------------------------------- 1 | from typing import List, Dict 2 | import json 3 | import os 4 | from pathlib import Path 5 | 6 | 7 | class ChatStorage: 8 | def __init__(self, storage_file: str = "chat_history.json"): 9 | self.storage_file = storage_file 10 | self.base_dir = Path(__file__).parent.parent 11 | self.storage_path = self.base_dir / storage_file 12 | 13 | def save_chat_history(self, history_data: List[Dict]): 14 | """Save chat history to file""" 15 | try: 16 | with open(self.storage_path, "w", encoding="utf-8") as f: 17 | json.dump(history_data, f, ensure_ascii=False, indent=2) 18 | except Exception as e: 19 | print(f"Error saving chat history: {e}") 20 | 21 | def load_chat_history(self) -> List[Dict]: 22 | """Load chat history from file""" 23 | try: 24 | if self.storage_path.exists(): 25 | with open(self.storage_path, "r", encoding="utf-8") as f: 26 | return json.load(f) 27 | except Exception as e: 28 | print(f"Error loading chat history: {e}") 29 | 30 | return [] # Return empty list if file doesn't exist or there's an error 31 | -------------------------------------------------------------------------------- /utils/ollama_utils.py: -------------------------------------------------------------------------------- 1 | import requests 2 | import json 3 | import base64 4 | from datetime import datetime 5 | from PyQt6.QtCore import Qt, QThread, pyqtSignal, QByteArray, QBuffer, QIODevice 6 | from PyQt6.QtGui import QImage 7 | from utils.screenshot_utils import process_image 8 | from utils.settings_manager import get_ollama_url, get_system_prompt 9 | 10 | 11 | class OllamaThread(QThread): 12 | response_chunk_ready = pyqtSignal(str) 13 | response_complete = pyqtSignal() 14 | request_screenshot = pyqtSignal() 15 | debug_screenshot_ready = pyqtSignal(QImage) 16 | 17 | def __init__( 18 | self, messages, screenshots, model, temperature=None, context_size=None 19 | ): 20 | super().__init__() 21 | self.messages = messages 22 | self.screenshots = screenshots if screenshots else [] # List of screenshots 23 | self.MIN_IMAGE_SIZE = 256 24 | self.MAX_IMAGE_SIZE = 1280 25 | self.model = model 26 | self.temperature = temperature 27 | self.context_size = context_size 28 | 29 | def run(self): 30 | try: 31 | if not self.messages or self.messages[0].get("role") != "system": 32 | # Insert default system prompt if none exists 33 | self.messages.insert( 34 | 0, {"role": "system", "content": get_system_prompt()} 35 | ) 36 | 37 | if self.screenshots: # Check if we have any screenshots 38 | # Process all screenshots 39 | processed_images = [] 40 | for screenshot in self.screenshots: 41 | processed_image = self.process_image(screenshot) 42 | self.debug_screenshot_ready.emit(processed_image) 43 | 44 | # Save image to bytes using PNG format 45 | buffer = QByteArray() 46 | buffer_io = QBuffer(buffer) 47 | buffer_io.open(QIODevice.OpenModeFlag.WriteOnly) 48 | success = processed_image.save(buffer_io, "PNG") 49 | buffer_io.close() # Make sure to close the buffer 50 | 51 | if not success: 52 | raise Exception("Failed to save image to buffer") 53 | 54 | img_bytes = buffer.data() 55 | img_base64 = base64.b64encode(img_bytes).decode("utf-8") 56 | processed_images.append(img_base64) 57 | 58 | if len(self.messages) > 1: 59 | # Get the last user message 60 | last_message = self.messages[-1] 61 | 62 | # Add image placeholders to the content 63 | original_content = last_message["content"].strip() 64 | last_message["content"] = f"{original_content}".strip() 65 | 66 | # Add the base64 images 67 | last_message["images"] = processed_images 68 | 69 | print(f"Sending message with {len(processed_images)} images") # Debug print 70 | print(f"Message content: {last_message['content']}") # Debug print 71 | else: 72 | print("Warning: No user message to attach images to") 73 | 74 | # Build request parameters 75 | request_params = { 76 | "model": self.model, 77 | "messages": self.messages, 78 | "stream": True, 79 | "options": {}, 80 | } 81 | 82 | if self.temperature is not None: 83 | request_params["options"]["temperature"] = self.temperature 84 | if self.context_size is not None: 85 | request_params["options"]["context_size"] = self.context_size 86 | 87 | ollama_url = get_ollama_url() 88 | 89 | # Debug print the request 90 | print("Request to Ollama:") 91 | print(f"URL: {ollama_url}/api/chat") 92 | print("Messages structure:", json.dumps(request_params["messages"], indent=2)) 93 | 94 | # Make streaming request to Ollama API 95 | response = requests.post( 96 | f"{ollama_url}/api/chat", json=request_params, stream=True 97 | ) 98 | response.raise_for_status() 99 | 100 | full_response = "" 101 | for line in response.iter_lines(): 102 | if line: 103 | chunk = json.loads(line) 104 | if "message" in chunk and "content" in chunk["message"]: 105 | content = chunk["message"]["content"] 106 | full_response += content 107 | self.response_chunk_ready.emit(content) 108 | 109 | if not full_response: 110 | self.response_chunk_ready.emit("No response received from Ollama.") 111 | 112 | self.response_complete.emit() 113 | except Exception as e: 114 | self.response_chunk_ready.emit(f"Error: {str(e)}") 115 | self.response_complete.emit() 116 | 117 | def process_image(self, image): 118 | """Process image to ensure it meets Ollama's requirements.""" 119 | if isinstance(image, QImage): 120 | # Convert QImage to the right format if needed 121 | if image.format() != QImage.Format.Format_RGB32: 122 | image = image.convertToFormat(QImage.Format.Format_RGB32) 123 | 124 | # Scale image if needed while maintaining aspect ratio 125 | current_size = max(image.width(), image.height()) 126 | if current_size > self.MAX_IMAGE_SIZE: 127 | image = image.scaled( 128 | self.MAX_IMAGE_SIZE, 129 | self.MAX_IMAGE_SIZE, 130 | Qt.AspectRatioMode.KeepAspectRatio, 131 | Qt.TransformationMode.SmoothTransformation 132 | ) 133 | elif current_size < self.MIN_IMAGE_SIZE: 134 | image = image.scaled( 135 | self.MIN_IMAGE_SIZE, 136 | self.MIN_IMAGE_SIZE, 137 | Qt.AspectRatioMode.KeepAspectRatio, 138 | Qt.TransformationMode.SmoothTransformation 139 | ) 140 | 141 | print(f"Processed image size: {image.width()}x{image.height()}") # Debug print 142 | return image 143 | else: 144 | raise ValueError("Input must be a QImage") 145 | 146 | 147 | def load_ollama_models(): 148 | try: 149 | start_time = datetime.now() 150 | ollama_url = get_ollama_url() 151 | # Set a timeout to avoid hanging 152 | response = requests.get( 153 | f"{ollama_url}/api/tags", 154 | timeout=0.1, # 100ms timeout 155 | headers={ 156 | "Connection": "close", # Prevent keep-alive connections 157 | "Accept": "application/json", # Explicitly request JSON 158 | }, 159 | ) 160 | response.raise_for_status() 161 | # Use list comprehension instead of json parsing then list comprehension 162 | models = [model["name"] for model in response.json()["models"]] 163 | elapsed = (datetime.now() - start_time).total_seconds() 164 | print(f"Loading models took {elapsed:.2f} seconds") 165 | return models 166 | except requests.Timeout: 167 | print("Timeout loading models") 168 | return ["Timeout loading models"] 169 | except Exception as e: 170 | print(f"Error loading models: {e}") 171 | return ["Error loading models"] 172 | 173 | 174 | # Add this new function 175 | def reload_model_list(): 176 | return load_ollama_models() 177 | -------------------------------------------------------------------------------- /utils/provider_utils.py: -------------------------------------------------------------------------------- 1 | from typing import Tuple 2 | import requests 3 | import json 4 | import sys 5 | from datetime import datetime 6 | from PyQt6.QtCore import Qt, QThread, pyqtSignal 7 | from PyQt6.QtGui import QImage 8 | from utils.settings_manager import get_ollama_url, get_system_prompt, get_openai_key, get_openai_url, get_provider 9 | 10 | DEBUG = "-debug" in sys.argv 11 | 12 | class ProviderRequest(QThread): 13 | response_chunk_ready = pyqtSignal(str, str) 14 | response_complete = pyqtSignal(str) 15 | request_screenshot = pyqtSignal() 16 | debug_screenshot_ready = pyqtSignal(QImage) 17 | 18 | def __init__(self, messages, screenshots, model, temperature=None, context_size=None, message_id=None): 19 | super().__init__() 20 | self.messages = messages 21 | self.screenshots = screenshots if screenshots else [] 22 | self.model = model 23 | self.temperature = temperature 24 | self.context_size = context_size 25 | self.provider = get_provider() 26 | self.message_id = message_id 27 | 28 | # Add system prompt to messages 29 | system_prompt = get_system_prompt() 30 | if system_prompt and (not messages or messages[0]["role"] != "system"): 31 | self.messages = [{"role": "system", "content": system_prompt}] + self.messages 32 | 33 | # Provider-specific settings 34 | self.MIN_IMAGE_SIZE = 256 35 | self.MAX_IMAGE_SIZE = 1280 36 | self.api_key = get_openai_key() if self.provider == "openai" else None 37 | self.api_url = get_openai_url() if self.provider == "openai" else get_ollama_url() 38 | 39 | def run(self): 40 | try: 41 | if self.provider == "ollama": 42 | self._run_ollama_request() 43 | elif self.provider == "openai": 44 | self._run_openai_request() 45 | else: 46 | raise ValueError(f"Unsupported provider: {self.provider}") 47 | except Exception as e: 48 | self.response_chunk_ready.emit(f"Error: {str(e)}", self.message_id) 49 | self.response_complete.emit(self.message_id) 50 | 51 | def _run_ollama_request(self): 52 | try: 53 | # Format messages for Ollama's specific requirements 54 | formatted_messages = [] 55 | for msg in self.messages: 56 | content = msg["content"] 57 | if isinstance(content, list): 58 | # Extract text content and handle images 59 | text_parts = [] 60 | images = [] 61 | 62 | for item in content: 63 | if item["type"] == "text": 64 | text_parts.append(item["text"]) 65 | elif item["type"] == "image": 66 | # For Ollama, we need the base64 image data 67 | if "image_url" in item and "url" in item["image_url"]: 68 | # Extract base64 data from data URL 69 | url = item["image_url"]["url"] 70 | if url.startswith("data:image/"): 71 | # Extract base64 part after the comma 72 | base64_data = url.split(",", 1)[1] 73 | images.append(base64_data) 74 | 75 | # Create formatted message 76 | formatted_msg = { 77 | "role": msg["role"], 78 | "content": " ".join(text_parts) 79 | } 80 | 81 | # Add images if present 82 | if images: 83 | formatted_msg["images"] = images 84 | 85 | formatted_messages.append(formatted_msg) 86 | else: 87 | # Handle string content (like system messages) 88 | formatted_messages.append({ 89 | "role": msg["role"], 90 | "content": content 91 | }) 92 | 93 | # Build request parameters 94 | request_params = { 95 | "model": self.model, 96 | "messages": formatted_messages, 97 | "stream": True, 98 | "options": {}, 99 | } 100 | 101 | if self.temperature is not None: 102 | request_params["options"]["temperature"] = self.temperature 103 | if self.context_size is not None: 104 | request_params["options"]["context_size"] = self.context_size 105 | 106 | ollama_url = get_ollama_url() 107 | 108 | # Debug print the request 109 | if DEBUG: 110 | print("Request to Ollama:") 111 | print(f"URL: {ollama_url}/api/chat") 112 | print("Messages structure:", json.dumps(request_params["messages"], indent=2)) 113 | 114 | # Make streaming request to Ollama API 115 | response = requests.post( 116 | f"{ollama_url}/api/chat", 117 | json=request_params, 118 | stream=True 119 | ) 120 | response.raise_for_status() 121 | 122 | full_response = "" 123 | for line in response.iter_lines(): 124 | if line: 125 | chunk = json.loads(line) 126 | if "message" in chunk and "content" in chunk["message"]: 127 | content = chunk["message"]["content"] 128 | full_response += content 129 | self.response_chunk_ready.emit(content, self.message_id) 130 | 131 | if not full_response: 132 | self.response_chunk_ready.emit("No response received from Ollama.", self.message_id) 133 | 134 | self.response_complete.emit(self.message_id) 135 | 136 | except requests.ConnectionError: 137 | self.response_chunk_ready.emit("Error: Cannot connect to Ollama. Please check if Ollama is running.", self.message_id) 138 | self.response_complete.emit(self.message_id) 139 | except Exception as e: 140 | # Simplify generic error messages 141 | error_msg = str(e) 142 | if "ConnectionPool" in error_msg or "NewConnectionError" in error_msg: 143 | error_msg = "Cannot connect to Ollama. Please check if it's running." 144 | self.response_chunk_ready.emit(f"Error: {error_msg}", self.message_id) 145 | self.response_complete.emit(self.message_id) 146 | 147 | def _run_openai_request(self): 148 | try: 149 | if not self.api_key: 150 | self.response_chunk_ready.emit("Error: OpenAI API key not configured", self.message_id) 151 | self.response_complete.emit(self.message_id) 152 | return 153 | 154 | headers = { 155 | "Authorization": f"Bearer {self.api_key}", 156 | "Content-Type": "application/json", 157 | "Accept": "text/event-stream" 158 | } 159 | 160 | # Format messages for OpenAI's specific requirements 161 | formatted_messages = [] 162 | for msg in self.messages: 163 | content = msg["content"] 164 | if isinstance(content, list): 165 | # Convert our format to OpenAI's format 166 | openai_content = [] 167 | for item in content: 168 | if item["type"] == "text": 169 | openai_content.append({ 170 | "type": "text", 171 | "text": item["text"] 172 | }) 173 | elif item["type"] == "image": 174 | openai_content.append({ 175 | "type": "image_url", 176 | "image_url": { 177 | "url": item["image_url"]["url"] 178 | } 179 | }) 180 | formatted_messages.append({ 181 | "role": msg["role"], 182 | "content": openai_content 183 | }) 184 | else: 185 | # Handle string content (like system messages) 186 | formatted_messages.append({ 187 | "role": msg["role"], 188 | "content": content 189 | }) 190 | 191 | data = { 192 | "model": self.model, 193 | "messages": formatted_messages, 194 | "stream": True 195 | } 196 | 197 | if self.temperature is not None: 198 | data["temperature"] = self.temperature 199 | 200 | # Debug print the request 201 | if DEBUG: 202 | print("OpenAI Request:") 203 | print(f"URL: {self.api_url}/chat/completions") 204 | print("Messages structure:", json.dumps(data["messages"], indent=2)) 205 | 206 | response = requests.post( 207 | f"{self.api_url}/chat/completions", 208 | headers=headers, 209 | json=data, 210 | stream=True 211 | ) 212 | 213 | response.raise_for_status() 214 | 215 | if response.status_code != 200: 216 | error_msg = response.json().get("error", {}).get("message", "Unknown error") 217 | self.response_chunk_ready.emit(f"Error: {error_msg}", self.message_id) 218 | self.response_complete.emit(self.message_id) 219 | return 220 | 221 | for line in response.iter_lines(): 222 | if line: 223 | line = line.decode('utf-8') 224 | if line.startswith("data: "): 225 | if line == "data: [DONE]": 226 | break 227 | 228 | try: 229 | json_data = json.loads(line[6:]) # Skip "data: " prefix 230 | content = json_data["choices"][0]["delta"].get("content", "") 231 | if content: 232 | self.response_chunk_ready.emit(content, self.message_id) 233 | except json.JSONDecodeError: 234 | continue 235 | 236 | self.response_complete.emit(self.message_id) 237 | 238 | except requests.ConnectionError: 239 | self.response_chunk_ready.emit("Error: Cannot connect to OpenAI API. Please check your connection and API endpoint.", self.message_id) 240 | self.response_complete.emit(self.message_id) 241 | except Exception as e: 242 | # Simplify generic error messages 243 | error_msg = str(e) 244 | if "ConnectionPool" in error_msg or "NewConnectionError" in error_msg: 245 | error_msg = "Cannot connect to API endpoint. Please check your connection and settings." 246 | self.response_chunk_ready.emit(f"Error: {error_msg}", self.message_id) 247 | self.response_complete.emit(self.message_id) 248 | 249 | def process_image(self, image): 250 | """Process image to ensure it meets Ollama's requirements.""" 251 | if isinstance(image, QImage): 252 | # Convert QImage to the right format if needed 253 | if image.format() != QImage.Format.Format_RGB32: 254 | image = image.convertToFormat(QImage.Format.Format_RGB32) 255 | 256 | # Scale image if needed while maintaining aspect ratio 257 | current_size = max(image.width(), image.height()) 258 | if current_size > self.MAX_IMAGE_SIZE: 259 | image = image.scaled( 260 | self.MAX_IMAGE_SIZE, 261 | self.MAX_IMAGE_SIZE, 262 | Qt.AspectRatioMode.KeepAspectRatio, 263 | Qt.TransformationMode.SmoothTransformation 264 | ) 265 | elif current_size < self.MIN_IMAGE_SIZE: 266 | image = image.scaled( 267 | self.MIN_IMAGE_SIZE, 268 | self.MIN_IMAGE_SIZE, 269 | Qt.AspectRatioMode.KeepAspectRatio, 270 | Qt.TransformationMode.SmoothTransformation 271 | ) 272 | 273 | print(f"Processed image size: {image.width()}x{image.height()}") # Debug print 274 | return image 275 | else: 276 | raise ValueError("Input must be a QImage") 277 | 278 | def check_provider_status() -> Tuple[bool, str]: 279 | """Check if the selected provider (Ollama or OpenAI) is online.""" 280 | provider = get_provider() 281 | is_online = False 282 | 283 | try: 284 | if provider == "ollama": 285 | # Check Ollama status 286 | ollama_url = get_ollama_url() 287 | if ollama_url: 288 | try: 289 | response = requests.get(f"{ollama_url}/api/tags", timeout=0.1) 290 | is_online = (response.status_code == 200) 291 | except (requests.ConnectionError, requests.Timeout): 292 | is_online = False 293 | elif provider == "openai": 294 | # Check OpenAI status by making a test API call 295 | openai_api_key = get_openai_key() 296 | openai_url = get_openai_url() 297 | if openai_api_key and openai_url: 298 | try: 299 | response = requests.get( 300 | f"{openai_url}/models", 301 | headers={ 302 | "Authorization": f"Bearer {openai_api_key}", 303 | "Content-Type": "application/json" 304 | }, 305 | timeout=0.5 306 | ) 307 | is_online = (response.status_code == 200) 308 | except (requests.ConnectionError, requests.Timeout): 309 | is_online = False 310 | 311 | return is_online, provider 312 | 313 | except Exception as e: 314 | print(f"Error checking provider status: {e}") # Add logging 315 | return False, provider 316 | 317 | def request_models(provider=None): 318 | """Get available models based on the current provider from settings""" 319 | if provider is None: 320 | provider = get_provider() 321 | 322 | if DEBUG: 323 | print(f"Requesting models for provider: {provider}") 324 | 325 | try: 326 | start_time = datetime.now() 327 | 328 | # Common configuration 329 | request_config = { 330 | "timeout": 0.5, 331 | "headers": {"Accept": "application/json"} 332 | } 333 | 334 | if provider == "ollama": 335 | base_url = get_ollama_url() 336 | if not base_url: 337 | return ["Please configure Ollama URL"] 338 | 339 | request_config["url"] = f"{base_url}/api/tags" 340 | request_config["headers"]["Connection"] = "close" 341 | 342 | elif provider == "openai": 343 | api_key = get_openai_key() 344 | base_url = get_openai_url() 345 | 346 | if not api_key: 347 | return ["Please configure OpenAI API key"] 348 | if not base_url: 349 | return ["Please configure OpenAI URL"] 350 | 351 | request_config["url"] = f"{base_url}/models" 352 | request_config["headers"].update({ 353 | "Authorization": f"Bearer {api_key}", 354 | "Content-Type": "application/json" 355 | }) 356 | 357 | else: 358 | return [f"Invalid provider: {provider}"] 359 | 360 | # Make the request 361 | response = requests.get(**request_config) 362 | response.raise_for_status() 363 | 364 | # Parse response based on provider 365 | if provider == "ollama": 366 | models = [model["name"] for model in response.json().get("models", [])] 367 | else: # openai 368 | models = [model["id"] for model in response.json().get("data", [])] 369 | models = sorted(models) 370 | 371 | if DEBUG: 372 | elapsed = (datetime.now() - start_time).total_seconds() 373 | print(f"Loading models took {elapsed:.2f} seconds") 374 | if provider == "openai": 375 | print(response.json().get("data", [])) 376 | 377 | return models if models else [f"No {'compatible ' if provider == 'openai' else ''}models found"] 378 | 379 | except requests.Timeout: 380 | if DEBUG: 381 | print("Timeout loading models") 382 | return ["Error loading models"] 383 | except requests.ConnectionError: 384 | if DEBUG: 385 | print(f"Cannot connect to {provider.title()} API") 386 | return [f"Cannot connect to {provider.title()} API"] 387 | except Exception as e: 388 | if DEBUG: 389 | print(f"Error loading {provider} models: {e}") 390 | return [f"Error loading {provider.title()} models"] -------------------------------------------------------------------------------- /utils/screenshot_utils.py: -------------------------------------------------------------------------------- 1 | from PyQt6.QtWidgets import QWidget, QRubberBand, QApplication 2 | from PyQt6.QtCore import Qt, QRect, QPoint, pyqtSignal, QTimer, QPropertyAnimation, QEasingCurve 3 | from PyQt6.QtGui import ( 4 | QPainter, 5 | QPen, 6 | QColor, 7 | QPixmap, 8 | QCursor, 9 | ) 10 | 11 | 12 | class ScreenshotSelector(QWidget): 13 | screenshot_taken = pyqtSignal(object) 14 | 15 | def __init__(self, screenshot): 16 | super().__init__() 17 | self.setWindowFlags( 18 | Qt.WindowType.FramelessWindowHint 19 | | Qt.WindowType.WindowStaysOnTopHint 20 | | Qt.WindowType.Tool 21 | ) 22 | 23 | # Make widget focusable to receive key events 24 | self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) 25 | 26 | # Get all available screens and their screenshots 27 | self.screens = QApplication.screens() 28 | self.screen_shots = {} # Store screenshots for each screen 29 | 30 | # Capture initial screenshots for all screens 31 | for screen in self.screens: 32 | geometry = screen.geometry() 33 | self.screen_shots[screen] = screen.grabWindow( 34 | 0, 35 | 0, # Local coordinates for each screen 36 | 0, 37 | geometry.width(), 38 | geometry.height() 39 | ) 40 | 41 | self.current_screen = None 42 | self.screenshot = screenshot # Initial screenshot 43 | 44 | # Selection hasn't started 45 | self.selection_started = False 46 | 47 | self.begin = QPoint() 48 | self.end = QPoint() 49 | self.rubberband = QRubberBand(QRubberBand.Shape.Rectangle, self) 50 | self.setWindowOpacity(1.0) 51 | self.setCursor(Qt.CursorShape.CrossCursor) 52 | self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) 53 | 54 | # Fade animation 55 | self.fade_animation = QPropertyAnimation(self, b"windowOpacity") 56 | self.fade_animation.setDuration(150) # 150ms duration 57 | self.fade_animation.setEasingCurve(QEasingCurve.Type.InOutQuad) 58 | 59 | # Start timer to track cursor position 60 | self.cursor_timer = QTimer(self) 61 | self.cursor_timer.timeout.connect(self.track_cursor) 62 | self.cursor_timer.start(50) # Check every 50ms 63 | 64 | def keyPressEvent(self, event): 65 | if event.key() == Qt.Key.Key_Escape: 66 | self.close() 67 | else: 68 | super().keyPressEvent(event) 69 | 70 | def track_cursor(self): 71 | if not self.selection_started: 72 | cursor_pos = QCursor.pos() 73 | for screen in self.screens: 74 | if screen.geometry().contains(cursor_pos): 75 | if screen != self.current_screen: 76 | # Start fade out 77 | self.fade_animation.setStartValue(1.0) 78 | self.fade_animation.setEndValue(0.0) 79 | self.fade_animation.finished.connect( 80 | lambda: self.switch_screen(screen) 81 | ) 82 | self.fade_animation.start() 83 | break 84 | 85 | def switch_screen(self, new_screen): 86 | self.current_screen = new_screen 87 | 88 | # Capture fresh screenshot of the new screen 89 | geometry = new_screen.geometry() 90 | self.screen_shots[new_screen] = new_screen.grabWindow( 91 | 0, 92 | 0, # Local coordinates for each screen 93 | 0, 94 | geometry.width(), 95 | geometry.height() 96 | ) 97 | self.screenshot = self.screen_shots[new_screen] 98 | self.setGeometry(new_screen.geometry()) 99 | 100 | # Start fade in 101 | self.fade_animation.finished.disconnect() # Disconnect previous connection 102 | self.fade_animation.setStartValue(0.0) 103 | self.fade_animation.setEndValue(1.0) 104 | self.fade_animation.start() 105 | 106 | self.update() 107 | 108 | def paintEvent(self, event): 109 | if not self.current_screen: 110 | return 111 | 112 | painter = QPainter(self) 113 | 114 | # Draw the screenshot 115 | screen_rect = self.rect() 116 | painter.drawPixmap(screen_rect, self.screenshot) 117 | 118 | # Add dark overlay 119 | overlay = QColor(0, 0, 0, 120) 120 | painter.fillRect(screen_rect, overlay) 121 | 122 | if self.selection_started and not self.rubberband.isHidden(): 123 | selected_rect = self.rubberband.geometry() 124 | painter.setClipRect(selected_rect) 125 | painter.drawPixmap(screen_rect, self.screenshot) 126 | painter.setClipRect(screen_rect) 127 | painter.setPen(QPen(QColor(255, 0, 255), 2)) 128 | painter.drawRect(selected_rect) 129 | 130 | def mousePressEvent(self, event): 131 | if not self.current_screen: 132 | return 133 | 134 | # Only process left mouse button 135 | if event.button() != Qt.MouseButton.LeftButton: 136 | self.close() 137 | return 138 | 139 | cursor_pos = event.globalPosition().toPoint() 140 | if self.current_screen.geometry().contains(cursor_pos): 141 | self.selection_started = True 142 | self.begin = event.pos() 143 | self.end = event.pos() 144 | self.rubberband.setGeometry(QRect(self.begin, self.end)) 145 | self.rubberband.show() 146 | self.cursor_timer.stop() # Stop tracking cursor once selection starts 147 | 148 | def mouseMoveEvent(self, event): 149 | # Only process if left button is being held 150 | if not self.selection_started or event.buttons() != Qt.MouseButton.LeftButton: 151 | return 152 | 153 | if self.rubberband.isVisible(): 154 | self.end = event.pos() 155 | self.rubberband.setGeometry(QRect(self.begin, self.end).normalized()) 156 | self.update() 157 | 158 | def mouseReleaseEvent(self, event): 159 | # Only process left button release 160 | if not self.selection_started or event.button() != Qt.MouseButton.LeftButton or not self.rubberband.isVisible(): 161 | return 162 | 163 | self.rubberband.hide() 164 | self.end = event.pos() 165 | selected_rect = QRect(self.begin, self.end).normalized() 166 | 167 | # Get the device pixel ratio for the current screen 168 | device_pixel_ratio = self.current_screen.devicePixelRatio() 169 | 170 | # Convert the selected rectangle to screen coordinates 171 | selected_rect = QRect( 172 | int(selected_rect.x() * device_pixel_ratio), 173 | int(selected_rect.y() * device_pixel_ratio), 174 | int(selected_rect.width() * device_pixel_ratio), 175 | int(selected_rect.height() * device_pixel_ratio), 176 | ) 177 | 178 | if selected_rect.width() * selected_rect.height() < 64: 179 | screenshot = self.screenshot.copy() # Use the entire screenshot 180 | else: 181 | screenshot = self.screenshot.copy(selected_rect) # Use selected area 182 | 183 | self.screenshot_taken.emit(screenshot) 184 | self.close() 185 | 186 | def closeEvent(self, event): 187 | self.cursor_timer.stop() 188 | super().closeEvent(event) 189 | 190 | 191 | def process_image(pixmap, min_size=256, max_size=1280): 192 | # Convert QPixmap to QImage if it isn't already a QImage 193 | image = pixmap.toImage() if isinstance(pixmap, QPixmap) else pixmap 194 | 195 | # Get the original size of the image 196 | original_width = image.width() 197 | original_height = image.height() 198 | 199 | # Ensure the scaled image fits within our constraints while maintaining aspect ratio 200 | if original_width <= min_size and original_height <= min_size: 201 | scale_factor = min(min_size / original_width, min_size / original_height) 202 | elif original_width >= max_size and original_height >= max_size: 203 | scale_factor = max(max_size / original_width, max_size / original_height) 204 | else: 205 | scale_factor = 1.0 206 | 207 | # Ensure the aspect ratio is maintained 208 | if original_width > original_height: 209 | new_width = min(original_width * scale_factor, max_size) 210 | new_height = int(new_width / original_width * original_height) 211 | else: # original_width <= original_height 212 | new_height = min(original_height * scale_factor, max_size) 213 | new_width = int(original_width / original_height * new_height) 214 | 215 | # Create a scaled QImage object 216 | scaled_image = image.scaled( 217 | int(new_width), 218 | int(new_height), 219 | Qt.AspectRatioMode.KeepAspectRatio, 220 | Qt.TransformationMode.SmoothTransformation, 221 | ) 222 | 223 | return scaled_image 224 | -------------------------------------------------------------------------------- /utils/settings_manager.py: -------------------------------------------------------------------------------- 1 | import os 2 | import json 3 | from pathlib import Path 4 | 5 | 6 | class SettingsManager: 7 | DEFAULT_CONFIG_PATH = "config.json" 8 | 9 | @staticmethod 10 | def load_config(): 11 | config_path = Path() / SettingsManager.DEFAULT_CONFIG_PATH 12 | if not config_path.exists(): 13 | return {} 14 | 15 | with open(config_path, "r") as file: 16 | config = json.load(file) 17 | 18 | # Ensure default values are set 19 | config.setdefault("openai_url", "https://api.openai.com/v1") 20 | config.setdefault("openai_key", "") 21 | config.setdefault("openai_default_model", "llama3.1:8b") 22 | 23 | config.setdefault("ollama_url", "http://localhost:11434") 24 | config.setdefault("ollama_default_model", "llama3.1:8b") 25 | 26 | config.setdefault("temperature", None) 27 | config.setdefault("context_size", None) 28 | config.setdefault("system_prompt", "") 29 | config.setdefault("vision_capable_models", []) 30 | 31 | return config 32 | 33 | @staticmethod 34 | def save_config(config): 35 | with open(SettingsManager.DEFAULT_CONFIG_PATH, "w") as file: 36 | json.dump(config, file, indent=4) 37 | 38 | 39 | def get_system_prompt(): 40 | try: 41 | with open("config.json", "r") as f: 42 | settings = json.load(f) 43 | return settings.get( 44 | "system_prompt", 45 | "You are a helpful AI assistant, answer in same language of question.", 46 | ) 47 | except FileNotFoundError: 48 | return "You are a helpful AI assistant, answer in same language of question." 49 | 50 | 51 | def get_default_model(): 52 | try: 53 | with open("config.json", "r") as f: 54 | settings = json.load(f) 55 | if get_provider() == "openai": 56 | return settings.get("openai_default_model", "llama3.1:8b") 57 | else: 58 | return settings.get("ollama_default_model", "llama3.1:8b") 59 | except FileNotFoundError: 60 | return "llama3.1:8b" 61 | 62 | 63 | def load_settings_from_file(): 64 | return SettingsManager.load_config() 65 | 66 | 67 | def save_settings_to_file(settings): 68 | SettingsManager.save_config(settings) 69 | 70 | 71 | def get_provider(): 72 | settings = load_settings_from_file() 73 | return settings.get("provider", "ollama") 74 | 75 | 76 | def get_openai_key(): 77 | settings = load_settings_from_file() 78 | return settings.get("openai_key") 79 | 80 | 81 | def get_openai_url(): 82 | try: 83 | with open("config.json", "r") as f: 84 | settings = json.load(f) 85 | return settings.get("openai_url", "https://api.openai.com/v1") 86 | except FileNotFoundError: 87 | return "https://api.openai.com/v1" 88 | 89 | 90 | def get_ollama_url(): 91 | try: 92 | with open("config.json", "r") as f: 93 | settings = json.load(f) 94 | return settings.get("ollama_url", "http://localhost:11434") 95 | except FileNotFoundError: 96 | return "http://localhost:11434" 97 | --------------------------------------------------------------------------------