import re import sys import pynvml from pygments import highlight from pygments.formatters import HtmlFormatter from pygments.lexers import TextLexer, get_lexer_by_name, guess_lexer from pygments.util import ClassNotFound from PyQt6.QtCore import Qt, QThread, QTimer, pyqtSignal from PyQt6.QtGui import ( QColor, QFont, QIcon, QLinearGradient, QPainter, QPainterPath, QPalette, QPixmap, QTextBlockFormat, QTextCharFormat, QTextCursor, QTextDocument, QTextDocumentFragment, QTextLength, QTextTableCellFormat, QTextTableFormat, ) from PyQt6.QtWidgets import ( QApplication, QHBoxLayout, QLabel, QLineEdit, QMainWindow, QPushButton, QTextBrowser, QVBoxLayout, QWidget, ) import chatbot # How often the bottom-right GPU/VRAM readout refreshes. GPU_STATUS_INTERVAL_MS = 1000 # --- Visual theme ------------------------------------------------------ # An explicit dark palette rather than relying on the OS theme, so the app # looks the same everywhere instead of "Qt follows Windows' dark app theme". BG_APP = "#0b0d12" BG_PANEL = "#12151c" BG_ELEVATED = "#181c25" BORDER = "#262b36" BORDER_STRONG = "#333949" TEXT_PRIMARY = "#e6e8ee" TEXT_SECONDARY = "#8b92a5" ACCENT = "#6366f1" ACCENT_HOVER = "#7b7ef4" ACCENT_PRESSED = "#4f52d6" # (accent, background) per chat role, used for the accent stripe and tinted # card background of each message. ROLE_STYLES = { "user": ("#60a5fa", "#141b2b"), "assistant": ("#34d399", "#0f211c"), "system": ("#9aa1b4", "#161922"), } _STYLESHEET = f""" QMainWindow {{ background-color: {BG_APP}; }} QWidget {{ color: {TEXT_PRIMARY}; font-family: "Segoe UI Variable Text", "Segoe UI", sans-serif; font-size: 10.5pt; }} #header {{ border-bottom: 1px solid {BORDER}; }} #titleLabel {{ font-size: 15pt; font-weight: 600; color: {TEXT_PRIMARY}; }} #subtitleLabel {{ font-size: 9pt; color: {TEXT_SECONDARY}; }} #badge {{ background-color: {BG_ELEVATED}; border: 1px solid {BORDER}; border-radius: 11px; padding: 4px 12px; color: {TEXT_SECONDARY}; font-size: 9pt; }} QTextBrowser#chatView {{ background-color: {BG_PANEL}; border: 1px solid {BORDER}; border-radius: 14px; padding: 10px; }} QLineEdit#messageInput {{ background-color: {BG_ELEVATED}; border: 1px solid {BORDER}; border-radius: 19px; padding: 0 16px; color: {TEXT_PRIMARY}; selection-background-color: {ACCENT}; }} QLineEdit#messageInput:focus {{ border: 1px solid {ACCENT}; }} QLineEdit#messageInput:disabled {{ color: {TEXT_SECONDARY}; }} QPushButton#sendButton {{ background-color: {ACCENT}; color: #ffffff; border: none; border-radius: 19px; padding: 0 22px; font-weight: 600; }} QPushButton#sendButton:hover {{ background-color: {ACCENT_HOVER}; }} QPushButton#sendButton:pressed {{ background-color: {ACCENT_PRESSED}; }} QPushButton#sendButton:disabled {{ background-color: {BG_ELEVATED}; color: {TEXT_SECONDARY}; border: 1px solid {BORDER}; }} QStatusBar {{ background-color: {BG_APP}; border-top: 1px solid {BORDER}; color: {TEXT_SECONDARY}; font-size: 9pt; }} QStatusBar::item {{ border: none; }} QScrollBar:vertical {{ background: transparent; width: 10px; margin: 4px 2px 4px 0; }} QScrollBar::handle:vertical {{ background: {BORDER_STRONG}; border-radius: 5px; min-height: 24px; }} QScrollBar::handle:vertical:hover {{ background: {TEXT_SECONDARY}; }} QScrollBar::add-line:vertical, QScrollBar::sub-line:vertical {{ height: 0; }} QScrollBar::add-page:vertical, QScrollBar::sub-page:vertical {{ background: transparent; }} QToolTip {{ background-color: {BG_ELEVATED}; color: {TEXT_PRIMARY}; border: 1px solid {BORDER}; padding: 4px 8px; }} """ def _dark_palette() -> QPalette: """Baseline QPalette for the Fusion style, so anything the stylesheet doesn't reach (native dialogs, menus) still matches the dark theme.""" palette = QPalette() palette.setColor(QPalette.ColorRole.Window, QColor(BG_APP)) palette.setColor(QPalette.ColorRole.WindowText, QColor(TEXT_PRIMARY)) palette.setColor(QPalette.ColorRole.Base, QColor(BG_ELEVATED)) palette.setColor(QPalette.ColorRole.AlternateBase, QColor(BG_PANEL)) palette.setColor(QPalette.ColorRole.ToolTipBase, QColor(BG_ELEVATED)) palette.setColor(QPalette.ColorRole.ToolTipText, QColor(TEXT_PRIMARY)) palette.setColor(QPalette.ColorRole.Text, QColor(TEXT_PRIMARY)) palette.setColor(QPalette.ColorRole.PlaceholderText, QColor(TEXT_SECONDARY)) palette.setColor(QPalette.ColorRole.Button, QColor(BG_ELEVATED)) palette.setColor(QPalette.ColorRole.ButtonText, QColor(TEXT_PRIMARY)) palette.setColor(QPalette.ColorRole.Highlight, QColor(ACCENT)) palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff")) return palette def _make_app_icon() -> QIcon: """Small generated glyph for the window/taskbar icon, since the project ships no icon asset.""" size = 64 pixmap = QPixmap(size, size) pixmap.fill(QColor(0, 0, 0, 0)) painter = QPainter(pixmap) painter.setRenderHint(QPainter.RenderHint.Antialiasing) gradient = QLinearGradient(0, 0, size, size) gradient.setColorAt(0.0, QColor(ACCENT_HOVER)) gradient.setColorAt(1.0, QColor(ACCENT_PRESSED)) path = QPainterPath() path.addRoundedRect(2, 2, size - 4, size - 4, 16, 16) painter.fillPath(path, gradient) painter.setPen(QColor("#ffffff")) painter.setFont(QFont("Segoe UI", 28, QFont.Weight.Bold)) painter.drawText(pixmap.rect(), Qt.AlignmentFlag.AlignCenter, "D") painter.end() return QIcon(pixmap) # GitHub dialect adds fenced code blocks with language tags, tables, and # strikethrough on top of CommonMark; NoHTML stops raw HTML in the model's # output from being interpreted as literal markup in the chat view. _MARKDOWN_FEATURES = ( QTextDocument.MarkdownFeature.MarkdownDialectGitHub | QTextDocument.MarkdownFeature.MarkdownNoHTML ) # Matches fenced code blocks (```lang\ncode\n```) so they can be pulled out # and rendered through Pygments instead of QTextDocumentFragment.fromMarkdown, # which only gives code a monospace font and no syntax colors. _CODE_FENCE_RE = re.compile(r"```([\w+-]*)\n(.*?)```", re.DOTALL) # noclasses=True inlines every color as a style="..." attribute rather than # relying on a