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, QUrl, pyqtSignal from PyQt6.QtGui import ( QColor, QDesktopServices, QFont, QIcon, QLinearGradient, QPainter, QPainterPath, QPalette, QPixmap, QTextBlockFormat, QTextCharFormat, QTextCursor, QTextDocument, QTextDocumentFragment, QTextLength, QTextTableCellFormat, QTextTableFormat, ) from PyQt6.QtWidgets import ( QApplication, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, QMainWindow, QMenu, QMessageBox, 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". # Brand palette: jet black / blue slate / silver / white / coral glow. JET_BLACK = "#2d3142" BLUE_SLATE = "#4f5d75" SILVER = "#bfc0c0" WHITE = "#ffffff" CORAL_GLOW = "#ef8354" BG_APP = "#242735" # jet black, darkened further for the outer chrome BG_PANEL = JET_BLACK BG_ELEVATED = BLUE_SLATE BORDER = "rgba(191, 192, 192, 0.35)" # silver, translucent BORDER_STRONG = "rgba(191, 192, 192, 0.55)" TEXT_PRIMARY = WHITE TEXT_SECONDARY = SILVER ACCENT = CORAL_GLOW ACCENT_HOVER = "#ff9768" ACCENT_PRESSED = "#cb6f47" # Green -> yellow -> red ramp used to color the GPU/VRAM load readout. LOAD_LOW = (0x22, 0xc5, 0x5e) LOAD_MID = (0xea, 0xb3, 0x08) LOAD_HIGH = (0xef, 0x44, 0x44) def _load_color(pct: float) -> str: """Hex color for a 0-100 load percentage, interpolated low->mid->high.""" pct = max(0.0, min(100.0, pct)) if pct <= 50: c1, c2, t = LOAD_LOW, LOAD_MID, pct / 50.0 else: c1, c2, t = LOAD_MID, LOAD_HIGH, (pct - 50) / 50.0 r = round(c1[0] + (c2[0] - c1[0]) * t) g = round(c1[1] + (c2[1] - c1[1]) * t) b = round(c1[2] + (c2[2] - c1[2]) * t) return f"#{r:02x}{g:02x}{b:02x}" # (accent, background) per chat role, used for the accent stripe and tinted # card background of each message. ROLE_STYLES = { "user": (CORAL_GLOW, "#3a2c26"), "assistant": (SILVER, "#2f3342"), "system": (BLUE_SLATE, "#262a36"), "thinking": (TEXT_SECONDARY, "#282b38"), } # Prefix for the fake anchor href used to make a thinking block's header # clickable (e.g. "think:think-3"), intercepted in _on_chat_anchor_clicked # rather than let QTextBrowser try to navigate to it. _THINK_ANCHOR_PREFIX = "think:" _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; }} #titleLabel {{ font-size: 15pt; font-weight: 600; color: {TEXT_PRIMARY}; }} #sidebar {{ background-color: {BG_APP}; border-right: 1px solid {BORDER}; }} QPushButton#newChatButton {{ background-color: {BG_ELEVATED}; border: 1px solid {BORDER}; padding: 8px 12px; color: {TEXT_PRIMARY}; font-weight: 600; text-align: left; }} QPushButton#newChatButton:hover {{ border: 1px solid {ACCENT}; }} QListWidget#sessionList {{ background-color: transparent; border: none; outline: none; }} QListWidget#sessionList::item {{ padding: 8px 10px; color: {TEXT_SECONDARY}; }} QListWidget#sessionList::item:selected {{ background-color: {BG_ELEVATED}; color: {TEXT_PRIMARY}; }} QListWidget#sessionList::item:hover {{ background-color: rgba(191, 192, 192, 0.08); }} #badge {{ background-color: {BG_ELEVATED}; border: 1px solid {BORDER}; padding: 4px 12px; color: {TEXT_SECONDARY}; font-size: 9pt; }} QTextBrowser#chatView {{ background-color: {BG_PANEL}; border: 1px solid {BORDER}; padding: 10px; }} QLineEdit#messageInput {{ background-color: {BG_ELEVATED}; border: 1px solid {BORDER}; 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: {WHITE}; border: none; 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}; 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(WHITE)) 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(JET_BLACK)) gradient.setColorAt(1.0, QColor(CORAL_GLOW)) path = QPainterPath() path.addRect(2, 2, size - 4, size - 4) painter.fillPath(path, gradient) painter.setPen(QColor(WHITE)) 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