├── .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 | 
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 |
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'
'
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'
'
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 |
Checking connection status...
448 |