#!/usr/bin/env python3 """ Enhanced Modern UI for GPT-OSS-120B Chat Interface """ import sys import time import threading import markdown from PyQt5.QtWidgets import (QApplication, QMainWindow, QWidget, QVBoxLayout, QHBoxLayout, QTextEdit, QLineEdit, QPushButton, QLabel, QScrollArea, QFrame, QGroupBox, QSpinBox, QSizePolicy, QProgressBar, QSplitter, QToolButton, QMenu, QAction, QFileDialog, QMessageBox) from PyQt5.QtCore import Qt, QThread, pyqtSignal, QTimer, QSize from PyQt5.QtGui import QFont, QTextCursor, QPalette, QColor, QIcon, QTextCharFormat, QSyntaxHighlighter, QTextDocument from mlx_lm import load, generate import logging import re import json from datetime import datetime from typing import List, Dict logging.basicConfig(level=logging.INFO, format='%(asctime)s - %(levelname)s - %(message)s') logger = logging.getLogger(__name__) class ModelLoaderThread(QThread): """Thread for loading the model to prevent UI freezing""" model_loaded = pyqtSignal() model_error = pyqtSignal(str) progress_update = pyqtSignal(str) def __init__(self): super().__init__() def run(self): try: self.progress_update.emit("Downloading model files...") logger.info("🚀 Loading GPT-OSS-120B...") model, tokenizer = load("mlx-community/gpt-oss-120b-MXFP4-Q4") logger.info("✅ Model loaded successfully!") self.progress_update.emit("Model loaded successfully!") self.model_loaded.emit() except Exception as e: logger.error(f"Failed to load model: {e}") self.model_error.emit(str(e)) class GenerationThread(QThread): """Thread for generating responses to prevent UI freezing""" response_ready = pyqtSignal(str, float) error_occurred = pyqtSignal(str) progress_update = pyqtSignal(str) def __init__(self, model, tokenizer, prompt, max_tokens): super().__init__() self.model = model self.tokenizer = tokenizer self.prompt = prompt self.max_tokens = max_tokens def run(self): try: start_time = time.time() # Format prompt with chat template self.progress_update.emit("Formatting prompt...") messages = [{"role": "user", "content": self.prompt}] formatted_prompt = self.tokenizer.apply_chat_template( messages, add_generation_prompt=True ) # Generate response self.progress_update.emit("Generating response...") response = generate( self.model, self.tokenizer, prompt=formatted_prompt, max_tokens=self.max_tokens, verbose=False ) # Extract and clean the final response self.progress_update.emit("Processing response...") final_response = self.extract_final_response(response) generation_time = time.time() - start_time self.response_ready.emit(final_response, generation_time) except Exception as e: self.error_occurred.emit(str(e)) def extract_final_response(self, response: str) -> str: """Extract the final assistant response from the chat template""" # Look for the final assistant response if "<|start|>assistant" in response: parts = response.split("<|start|>assistant") if len(parts) > 1: final_part = parts[-1] # Remove all channel and message tags final_part = re.sub(r'<\|channel\|>[^<]+', '', final_part) final_part = final_part.replace('<|message|>', '') final_part = final_part.replace('<|end|>', '') # Clean up any remaining tags or whitespace final_part = re.sub(r'<[^>]+>', '', final_part) final_part = final_part.strip() if final_part: return final_part # Fallback: return the original response cleaned up cleaned = re.sub(r'<\|[^>]+\|>', '', response) cleaned = re.sub(r'<[^>]+>', '', cleaned) return cleaned.strip() class CodeHighlighter(QSyntaxHighlighter): """Basic syntax highlighter for code blocks""" def __init__(self, parent=None): super().__init__(parent) self.highlighting_rules = [] # Keyword format keyword_format = QTextCharFormat() keyword_format.setForeground(QColor("#569CD6")) keyword_format.setFontWeight(QFont.Bold) keywords = ["def", "class", "return", "import", "from", "as", "if", "else", "elif", "for", "while", "try", "except", "finally"] for word in keywords: pattern = r'\b' + word + r'\b' self.highlighting_rules.append((re.compile(pattern), keyword_format)) # String format string_format = QTextCharFormat() string_format.setForeground(QColor("#CE9178")) self.highlighting_rules.append((re.compile(r'\".*\"'), string_format)) self.highlighting_rules.append((re.compile(r'\'.*\''), string_format)) # Comment format comment_format = QTextCharFormat() comment_format.setForeground(QColor("#6A9955")) self.highlighting_rules.append((re.compile(r'#.*'), comment_format)) def highlightBlock(self, text): for pattern, format in self.highlighting_rules: for match in pattern.finditer(text): start, end = match.span() self.setFormat(start, end - start, format) class ChatMessageWidget(QWidget): """Custom widget for displaying chat messages""" def __init__(self, is_user, message, timestamp=None, generation_time=None): super().__init__() self.is_user = is_user layout = QVBoxLayout() layout.setContentsMargins(15, 8, 15, 8) # Header with sender info and timestamp header_layout = QHBoxLayout() sender_icon = QLabel("👤" if is_user else "🤖") sender_label = QLabel("You" if is_user else "GPT-OSS-120B") sender_label.setStyleSheet("font-weight: bold; color: #2E86AB;" if is_user else "font-weight: bold; color: #A23B72;") time_text = timestamp if timestamp else datetime.now().strftime("%H:%M:%S") time_label = QLabel(time_text) time_label.setStyleSheet("color: #777; font-size: 11px;") header_layout.addWidget(sender_icon) header_layout.addWidget(sender_label) header_layout.addStretch() header_layout.addWidget(time_label) if generation_time and not is_user: speed_label = QLabel(f"{generation_time:.1f}s") speed_label.setStyleSheet("color: #777; font-size: 11px;") header_layout.addWidget(speed_label) layout.addLayout(header_layout) # Message content - use QTextEdit for proper text rendering message_display = QTextEdit() message_display.setReadOnly(True) # Format message with basic markdown support formatted_message = self.format_message(message) message_display.setHtml(formatted_message) message_display.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) message_display.setHorizontalScrollBarPolicy(Qt.ScrollBarAsNeeded) message_display.setStyleSheet(""" QTextEdit { background-color: %s; border: 1px solid %s; border-radius: 12px; padding: 12px; margin: 2px; font-size: 14px; } """ % ("#E8F4F8" if is_user else "#F8F0F5", "#B8D8E8" if is_user else "#E8C6DE")) # Set size policy message_display.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Preferred) message_display.setMinimumHeight(50) message_display.setMaximumHeight(600) # Add syntax highlighter for code blocks if not is_user and self.contains_code(message): highlighter = CodeHighlighter(message_display.document()) layout.addWidget(message_display) self.setLayout(layout) def format_message(self, message): """Format message with basic HTML styling""" # Convert markdown to basic HTML html = markdown.markdown(message) # Add some basic styling styled_html = f"""
{html}
""" return styled_html def contains_code(self, message): """Check if message contains code-like content""" code_indicators = ["def ", "class ", "import ", "function ", "var ", "const ", "=", "()", "{}", "[]"] return any(indicator in message for indicator in code_indicators) class GPTOSSChatUI(QMainWindow): def __init__(self): super().__init__() self.model = None self.tokenizer = None self.conversation_history = [] self.max_tokens = 2048 self.generation_thread = None self.model_loader_thread = None self.init_ui() self.load_model_in_background() def init_ui(self): """Initialize the user interface""" self.setWindowTitle("GPT-OSS-120B Chat") self.setGeometry(100, 100, 1400, 900) # Central widget central_widget = QWidget() self.setCentralWidget(central_widget) # Main layout main_layout = QHBoxLayout(central_widget) main_layout.setContentsMargins(15, 15, 15, 15) main_layout.setSpacing(15) # Left panel for settings left_panel = QFrame() left_panel.setMinimumWidth(280) left_panel.setMaximumWidth(350) left_panel.setFrameShape(QFrame.StyledPanel) left_panel_layout = QVBoxLayout(left_panel) left_panel_layout.setContentsMargins(12, 12, 12, 12) # App title title_label = QLabel("GPT-OSS-120B Chat") title_label.setStyleSheet("font-size: 18px; font-weight: bold; color: #2E86AB; margin-bottom: 15px;") title_label.setAlignment(Qt.AlignCenter) left_panel_layout.addWidget(title_label) # Model info model_info_group = QGroupBox("🤖 Model Information") model_info_group.setStyleSheet("QGroupBox { font-weight: bold; }") model_info_layout = QVBoxLayout() model_details = [ ("GPT-OSS-120B", "font-weight: bold; font-size: 14px; color: #333;"), ("120B parameters, 4-bit quantized", "color: #666; font-size: 12px;"), ("Apple M3 Ultra • 512GB RAM", "color: #666; font-size: 12px;"), ("Performance: ~95 tokens/second", "color: #4CAF50; font-size: 12px; font-weight: bold;") ] for text, style in model_details: label = QLabel(text) label.setStyleSheet(style) label.setWordWrap(True) model_info_layout.addWidget(label) model_info_group.setLayout(model_info_layout) left_panel_layout.addWidget(model_info_group) # Generation settings settings_group = QGroupBox("⚙️ Generation Settings") settings_group.setStyleSheet("QGroupBox { font-weight: bold; }") settings_layout = QVBoxLayout() # Max tokens setting tokens_layout = QHBoxLayout() tokens_label = QLabel("Max Tokens:") tokens_label.setStyleSheet("font-weight: bold;") self.tokens_spinner = QSpinBox() self.tokens_spinner.setRange(128, 4096) self.tokens_spinner.setValue(2048) self.tokens_spinner.valueChanged.connect(self.update_max_tokens) self.tokens_spinner.setStyleSheet("padding: 6px; border-radius: 4px;") tokens_layout.addWidget(tokens_label) tokens_layout.addWidget(self.tokens_spinner) settings_layout.addLayout(tokens_layout) settings_group.setLayout(settings_layout) left_panel_layout.addWidget(settings_group) # Conversation management conv_group = QGroupBox("💬 Conversation") conv_group.setStyleSheet("QGroupBox { font-weight: bold; }") conv_layout = QVBoxLayout() clear_btn = QPushButton("🗑️ Clear Conversation") clear_btn.clicked.connect(self.clear_conversation) clear_btn.setStyleSheet("text-align: left; padding: 8px;") conv_layout.addWidget(clear_btn) export_btn = QPushButton("💾 Export Conversation") export_btn.clicked.connect(self.export_conversation) export_btn.setStyleSheet("text-align: left; padding: 8px;") conv_layout.addWidget(export_btn) conv_group.setLayout(conv_layout) left_panel_layout.addWidget(conv_group) left_panel_layout.addStretch() # Status indicator self.status_indicator = QLabel("🟡 Loading model...") self.status_indicator.setStyleSheet("color: #666; font-size: 11px; margin-top: 10px;") left_panel_layout.addWidget(self.status_indicator) # Right panel for chat right_panel = QWidget() right_panel_layout = QVBoxLayout(right_panel) right_panel_layout.setContentsMargins(0, 0, 0, 0) # Chat history area self.chat_scroll = QScrollArea() self.chat_scroll.setWidgetResizable(True) self.chat_scroll.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded) self.chat_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self.chat_scroll.setStyleSheet("background-color: #FAFAFA; border: none;") self.chat_container = QWidget() self.chat_layout = QVBoxLayout(self.chat_container) self.chat_layout.setAlignment(Qt.AlignTop) self.chat_layout.setSpacing(10) self.chat_layout.setContentsMargins(10, 10, 10, 10) self.chat_scroll.setWidget(self.chat_container) right_panel_layout.addWidget(self.chat_scroll) # Input area input_frame = QFrame() input_frame.setStyleSheet("background-color: white; border-top: 1px solid #EEE;") input_layout = QVBoxLayout(input_frame) input_layout.setContentsMargins(15, 15, 15, 15) # Message input with character count input_top_layout = QHBoxLayout() self.message_input = QTextEdit() self.message_input.setPlaceholderText("Type your message here... (Shift+Enter for new line)") self.message_input.setMaximumHeight(100) self.message_input.setStyleSheet(""" QTextEdit { padding: 12px; border: 2px solid #DDD; border-radius: 8px; font-size: 14px; } QTextEdit:focus { border-color: #2E86AB; } """) self.message_input.textChanged.connect(self.update_char_count) input_top_layout.addWidget(self.message_input) self.send_btn = QPushButton("Send") self.send_btn.setFixedSize(80, 50) self.send_btn.clicked.connect(self.send_message) self.send_btn.setStyleSheet(""" QPushButton { background-color: #2E86AB; color: white; border: none; border-radius: 8px; font-weight: bold; } QPushButton:hover { background-color: #1F5E7A; } QPushButton:disabled { background-color: #CCCCCC; } """) input_top_layout.addWidget(self.send_btn) input_layout.addLayout(input_top_layout) # Character count and controls bottom_layout = QHBoxLayout() self.char_count = QLabel("0 characters") self.char_count.setStyleSheet("color: #777; font-size: 11px;") bottom_layout.addWidget(self.char_count) bottom_layout.addStretch() # Add some utility buttons clear_input_btn = QPushButton("Clear Input") clear_input_btn.setStyleSheet("font-size: 11px; padding: 4px 8px;") clear_input_btn.clicked.connect(self.clear_input) bottom_layout.addWidget(clear_input_btn) input_layout.addLayout(bottom_layout) right_panel_layout.addWidget(input_frame) # Add panels to main layout main_layout.addWidget(left_panel) main_layout.addWidget(right_panel) # Status bar self.statusBar().showMessage("Ready") # Set styles self.apply_styles() def apply_styles(self): """Apply modern styling to the UI""" self.setStyleSheet(""" QMainWindow { background-color: #F5F5F7; } QGroupBox { font-weight: bold; border: 1px solid #E0E0E0; border-radius: 8px; margin-top: 10px; padding-top: 20px; background-color: white; } QGroupBox::title { subcontrol-origin: margin; left: 10px; padding: 0 8px 0 8px; color: #2E86AB; } QPushButton { background-color: #2E86AB; color: white; border: none; padding: 8px 16px; border-radius: 6px; font-weight: bold; } QPushButton:hover { background-color: #1F5E7A; } QPushButton:disabled { background-color: #CCCCCC; } QScrollArea { border: none; background-color: #FAFAFA; } QSpinBox { padding: 6px; border: 1px solid #DDD; border-radius: 4px; background-color: white; } QFrame { background-color: white; border-radius: 8px; } """) def update_char_count(self): """Update character count label""" text = self.message_input.toPlainText() self.char_count.setText(f"{len(text)} characters") def clear_input(self): """Clear the input field""" self.message_input.clear() def load_model_in_background(self): """Load the model in a separate thread to prevent UI freezing""" self.statusBar().showMessage("Loading model...") self.status_indicator.setText("🟡 Loading model...") self.send_btn.setEnabled(False) self.message_input.setEnabled(False) self.tokens_spinner.setEnabled(False) self.model_loader_thread = ModelLoaderThread() self.model_loader_thread.model_loaded.connect(self.model_loaded) self.model_loader_thread.model_error.connect(self.model_error) self.model_loader_thread.progress_update.connect(self.update_progress) self.model_loader_thread.start() def update_progress(self, message): """Update progress message""" self.status_indicator.setText(f"🟡 {message}") def model_loaded(self): """Called when model is successfully loaded""" from mlx_lm import load, generate # Load the model in the main thread try: self.model, self.tokenizer = load("mlx-community/gpt-oss-120b-MXFP4-Q4") self.statusBar().showMessage("Model loaded and ready!") self.status_indicator.setText("🟢 Model loaded and ready!") self.send_btn.setEnabled(True) self.message_input.setEnabled(True) self.tokens_spinner.setEnabled(True) # Add welcome message welcome_msg = """Hello! I'm GPT-OSS-120B, running locally on your M3 Ultra. I'm a 120 billion parameter open-source language model, and I'm here to assist you with: - Answering questions - Generating creative content - Explaining complex concepts - Writing and analyzing code - And much more! How can I help you today?""" self.add_message(False, welcome_msg, 0.0) # Scroll to bottom after a short delay to ensure UI is rendered QTimer.singleShot(100, self.scroll_to_bottom) except Exception as e: self.model_error(str(e)) def model_error(self, error_msg): """Called when model loading fails""" self.statusBar().showMessage(f"Error loading model: {error_msg}") self.status_indicator.setText(f"🔴 Error: {error_msg}") error_widget = ChatMessageWidget(False, f"Error loading model: {error_msg}") self.chat_layout.addWidget(error_widget) self.send_btn.setEnabled(False) self.message_input.setEnabled(False) def send_message(self): """Send the current message""" message = self.message_input.toPlainText().strip() if not message or not self.model: return # Add user message to chat self.add_message(True, message) self.message_input.clear() # Disable input while generating self.send_btn.setEnabled(False) self.message_input.setEnabled(False) self.tokens_spinner.setEnabled(False) self.statusBar().showMessage("Generating response...") self.status_indicator.setText("🟡 Generating response...") # Generate response in a separate thread self.generation_thread = GenerationThread( self.model, self.tokenizer, message, self.max_tokens ) self.generation_thread.response_ready.connect(self.handle_response) self.generation_thread.error_occurred.connect(self.handle_error) self.generation_thread.progress_update.connect(self.update_progress) self.generation_thread.start() def handle_response(self, response, generation_time): """Handle the generated response""" self.add_message(False, response, generation_time) # Re-enable input self.send_btn.setEnabled(True) self.message_input.setEnabled(True) self.tokens_spinner.setEnabled(True) self.statusBar().showMessage("Ready") self.status_indicator.setText("🟢 Ready") # Scroll to bottom self.scroll_to_bottom() def handle_error(self, error_msg): """Handle generation errors""" self.add_message(False, f"Error: {error_msg}", 0.0) # Re-enable input self.send_btn.setEnabled(True) self.message_input.setEnabled(True) self.tokens_spinner.setEnabled(True) self.statusBar().showMessage("Error occurred") self.status_indicator.setText("🔴 Error occurred") # Scroll to bottom self.scroll_to_bottom() def add_message(self, is_user, message, generation_time=0.0): """Add a message to the chat history""" # Add to conversation history self.conversation_history.append({ "is_user": is_user, "message": message, "timestamp": datetime.now().strftime("%H:%M:%S"), "generation_time": generation_time }) # Create and add message widget message_widget = ChatMessageWidget(is_user, message, datetime.now().strftime("%H:%M:%S"), generation_time) self.chat_layout.addWidget(message_widget) def clear_conversation(self): """Clear the conversation history""" # Clear history self.conversation_history = [] # Remove all message widgets for i in reversed(range(self.chat_layout.count())): widget = self.chat_layout.itemAt(i).widget() if widget: widget.setParent(None) # Add welcome message again welcome_msg = "Hello! I'm GPT-OSS-120B. How can I assist you today?" self.add_message(False, welcome_msg, 0.0) # Scroll to bottom self.scroll_to_bottom() def export_conversation(self): """Export the conversation to a file""" try: options = QFileDialog.Options() file_path, _ = QFileDialog.getSaveFileName( self, "Save Conversation", "conversation.json", "JSON Files (*.json)", options=options ) if file_path: if not file_path.endswith('.json'): file_path += '.json' with open(file_path, 'w', encoding='utf-8') as f: json.dump(self.conversation_history, f, indent=2, ensure_ascii=False) QMessageBox.information(self, "Success", f"Conversation exported to {file_path}") except Exception as e: QMessageBox.critical(self, "Error", f"Failed to export conversation: {str(e)}") def update_max_tokens(self, value): """Update the maximum tokens for generation""" self.max_tokens = value def scroll_to_bottom(self): """Scroll the chat area to the bottom""" scrollbar = self.chat_scroll.verticalScrollBar() scrollbar.setValue(scrollbar.maximum()) def keyPressEvent(self, event): """Handle key press events""" if event.key() == Qt.Key_Return and event.modifiers() & Qt.ShiftModifier: # Allow Shift+Enter for new lines self.message_input.insertPlainText("\n") elif event.key() == Qt.Key_Return: # Send message on Enter (without Shift) self.send_message() else: super().keyPressEvent(event) def main(): app = QApplication(sys.argv) # Set application style and font app.setStyle('Fusion') font = QFont("SF Pro Text", 12) # Use system font app.setFont(font) # Create and show the main window chat_ui = GPTOSSChatUI() chat_ui.show() sys.exit(app.exec_()) if __name__ == "__main__": main()