feat(gui): aggiunge interfaccia desktop PySide6 con tema light/dark
- chat con Markdown e formule KaTeX (via QWebEngineView + CDN) - top bar con selezione collezione ChromaDB e bottone ☀️/🌙 - input multiriga con Enter per inviare e Shift+Enter per andare a capo - animazione thinking durante la generazione - fonti dei chunk mostrate sotto ogni risposta Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,251 @@
|
||||
from __future__ import annotations
|
||||
import json
|
||||
import sys
|
||||
from pathlib import Path
|
||||
|
||||
sys.path.insert(0, str(Path(__file__).parent.parent))
|
||||
|
||||
import chromadb
|
||||
from PySide6.QtCore import Qt, QEvent, QUrl
|
||||
from PySide6.QtWidgets import (
|
||||
QMainWindow, QWidget, QVBoxLayout, QHBoxLayout,
|
||||
QLabel, QComboBox, QTextEdit, QPushButton,
|
||||
)
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
from gui.worker import RagWorker
|
||||
|
||||
_HERE = Path(__file__).parent
|
||||
_PROJECT = _HERE.parent
|
||||
_CHROMA_DIR = _PROJECT / "chroma_db"
|
||||
_CHAT_HTML = _HERE / "chat.html"
|
||||
|
||||
# ── Temi ──────────────────────────────────────────────────────────────────────
|
||||
|
||||
_THEMES = {
|
||||
"dark": {
|
||||
"topbar": "background:#171717; border-bottom:1px solid #2e2e2e;",
|
||||
"lbl": "color:#aaa; font-size:13px;",
|
||||
"combo": """
|
||||
QComboBox {
|
||||
background:#2a2a2a; color:#ececec;
|
||||
border:1px solid #3a3a3a; border-radius:6px;
|
||||
padding:4px 10px; font-size:13px;
|
||||
}
|
||||
QComboBox::drop-down { border:none; }
|
||||
QComboBox QAbstractItemView {
|
||||
background:#2a2a2a; color:#ececec;
|
||||
selection-background-color:#3a3a3a;
|
||||
}
|
||||
""",
|
||||
"inputbar": "background:#171717; border-top:1px solid #2e2e2e;",
|
||||
"input": """
|
||||
QTextEdit {
|
||||
background:#2a2a2a; color:#ececec;
|
||||
border:1px solid #3a3a3a; border-radius:10px;
|
||||
padding:8px 12px; font-size:14px;
|
||||
}
|
||||
""",
|
||||
"theme_btn": "background:#2a2a2a; color:#ececec; border:1px solid #3a3a3a; border-radius:6px; font-size:16px; padding:4px 8px;",
|
||||
"icon": "☀️",
|
||||
},
|
||||
"light": {
|
||||
"topbar": "background:#f5f5f5; border-bottom:1px solid #e0e0e0;",
|
||||
"lbl": "color:#555; font-size:13px;",
|
||||
"combo": """
|
||||
QComboBox {
|
||||
background:#ffffff; color:#1a1a1a;
|
||||
border:1px solid #ddd; border-radius:6px;
|
||||
padding:4px 10px; font-size:13px;
|
||||
}
|
||||
QComboBox::drop-down { border:none; }
|
||||
QComboBox QAbstractItemView {
|
||||
background:#ffffff; color:#1a1a1a;
|
||||
selection-background-color:#e8e8e8;
|
||||
}
|
||||
""",
|
||||
"inputbar": "background:#f5f5f5; border-top:1px solid #e0e0e0;",
|
||||
"input": """
|
||||
QTextEdit {
|
||||
background:#ffffff; color:#1a1a1a;
|
||||
border:1px solid #ddd; border-radius:10px;
|
||||
padding:8px 12px; font-size:14px;
|
||||
}
|
||||
""",
|
||||
"theme_btn": "background:#fff; color:#1a1a1a; border:1px solid #ddd; border-radius:6px; font-size:16px; padding:4px 8px;",
|
||||
"icon": "🌙",
|
||||
},
|
||||
}
|
||||
|
||||
_BTN_SEND = """
|
||||
QPushButton {
|
||||
background:#10a37f; color:#fff;
|
||||
border:none; border-radius:8px; font-size:14px; font-weight:600;
|
||||
}
|
||||
QPushButton:hover { background:#1ab58e; }
|
||||
QPushButton:pressed { background:#0d8a6a; }
|
||||
QPushButton:disabled { background:#2a2a2a; color:#555; }
|
||||
"""
|
||||
|
||||
|
||||
class ChatWindow(QMainWindow):
|
||||
def __init__(self, default_collection: str = "") -> None:
|
||||
super().__init__()
|
||||
self.setWindowTitle("RAG Assistant")
|
||||
self.resize(1000, 720)
|
||||
|
||||
self._dark = True
|
||||
self._worker: RagWorker | None = None
|
||||
self._collection: chromadb.Collection | None = None
|
||||
self._chroma = chromadb.PersistentClient(path=str(_CHROMA_DIR))
|
||||
|
||||
self._build_ui()
|
||||
self._load_collections(default_collection)
|
||||
self._load_page()
|
||||
|
||||
# ── UI ────────────────────────────────────────────────────────────────────
|
||||
|
||||
def _build_ui(self) -> None:
|
||||
root = QWidget()
|
||||
self.setCentralWidget(root)
|
||||
layout = QVBoxLayout(root)
|
||||
layout.setSpacing(0)
|
||||
layout.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
# Top bar
|
||||
self._top = QWidget()
|
||||
self._top.setFixedHeight(46)
|
||||
tl = QHBoxLayout(self._top)
|
||||
tl.setContentsMargins(14, 0, 14, 0)
|
||||
tl.setSpacing(8)
|
||||
|
||||
self._lbl = QLabel("Collezione:")
|
||||
tl.addWidget(self._lbl)
|
||||
|
||||
self._combo = QComboBox()
|
||||
self._combo.setFixedWidth(260)
|
||||
self._combo.currentTextChanged.connect(self._on_collection_changed)
|
||||
tl.addWidget(self._combo)
|
||||
|
||||
tl.addStretch()
|
||||
|
||||
self._theme_btn = QPushButton()
|
||||
self._theme_btn.setFixedSize(36, 30)
|
||||
self._theme_btn.setToolTip("Cambia tema")
|
||||
self._theme_btn.clicked.connect(self._toggle_theme)
|
||||
tl.addWidget(self._theme_btn)
|
||||
|
||||
layout.addWidget(self._top)
|
||||
|
||||
# Web view
|
||||
self._web = QWebEngineView()
|
||||
s = self._web.settings()
|
||||
s.setAttribute(s.WebAttribute.JavascriptEnabled, True)
|
||||
s.setAttribute(s.WebAttribute.LocalContentCanAccessRemoteUrls, True)
|
||||
layout.addWidget(self._web, stretch=1)
|
||||
|
||||
# Input bar
|
||||
self._bar = QWidget()
|
||||
bl = QHBoxLayout(self._bar)
|
||||
bl.setContentsMargins(14, 10, 14, 12)
|
||||
bl.setSpacing(10)
|
||||
|
||||
self._input = QTextEdit()
|
||||
self._input.setPlaceholderText("Fai una domanda… (Invio per inviare, Shift+Invio per andare a capo)")
|
||||
self._input.setFixedHeight(68)
|
||||
self._input.installEventFilter(self)
|
||||
bl.addWidget(self._input, stretch=1)
|
||||
|
||||
self._btn = QPushButton("Invia")
|
||||
self._btn.setFixedSize(76, 42)
|
||||
self._btn.setStyleSheet(_BTN_SEND)
|
||||
self._btn.clicked.connect(self._on_send)
|
||||
bl.addWidget(self._btn)
|
||||
|
||||
layout.addWidget(self._bar)
|
||||
|
||||
self._apply_theme()
|
||||
|
||||
def _apply_theme(self) -> None:
|
||||
t = _THEMES["dark" if self._dark else "light"]
|
||||
self._top.setStyleSheet(t["topbar"])
|
||||
self._lbl.setStyleSheet(t["lbl"])
|
||||
self._combo.setStyleSheet(t["combo"])
|
||||
self._bar.setStyleSheet(t["inputbar"])
|
||||
self._input.setStyleSheet(t["input"])
|
||||
self._theme_btn.setStyleSheet(t["theme_btn"])
|
||||
self._theme_btn.setText(t["icon"])
|
||||
|
||||
# ── Init ──────────────────────────────────────────────────────────────────
|
||||
|
||||
def _load_collections(self, default: str) -> None:
|
||||
names = sorted(c.name for c in self._chroma.list_collections())
|
||||
self._combo.addItems(names)
|
||||
if default and default in names:
|
||||
self._combo.setCurrentText(default)
|
||||
|
||||
def _load_page(self) -> None:
|
||||
self._web.setUrl(QUrl.fromLocalFile(str(_CHAT_HTML)))
|
||||
|
||||
# ── Events ────────────────────────────────────────────────────────────────
|
||||
|
||||
def eventFilter(self, obj, event) -> bool:
|
||||
if obj is self._input and event.type() == QEvent.Type.KeyPress:
|
||||
if event.key() in (Qt.Key.Key_Return, Qt.Key.Key_Enter):
|
||||
if not (event.modifiers() & Qt.KeyboardModifier.ShiftModifier):
|
||||
self._on_send()
|
||||
return True
|
||||
return super().eventFilter(obj, event)
|
||||
|
||||
def _on_collection_changed(self, name: str) -> None:
|
||||
if name:
|
||||
self._collection = self._chroma.get_collection(name)
|
||||
|
||||
def _toggle_theme(self) -> None:
|
||||
self._dark = not self._dark
|
||||
self._apply_theme()
|
||||
theme = "dark" if self._dark else "light"
|
||||
self._js(f"setTheme({json.dumps(theme)})")
|
||||
|
||||
def _on_send(self) -> None:
|
||||
question = self._input.toPlainText().strip()
|
||||
if not question:
|
||||
return
|
||||
if not self._collection:
|
||||
self._js("addMessage('assistant', '⚠️ Seleziona una collezione prima di inviare.', [])")
|
||||
return
|
||||
|
||||
self._input.clear()
|
||||
self._set_enabled(False)
|
||||
self._js(f"addMessage('user', {json.dumps(question)}, [])")
|
||||
self._js("addThinking()")
|
||||
|
||||
self._worker = RagWorker(question, self._collection, parent=self)
|
||||
self._worker.finished.connect(self._on_response)
|
||||
self._worker.error.connect(self._on_error)
|
||||
self._worker.start()
|
||||
|
||||
def _on_response(self, response: str, chunks: list) -> None:
|
||||
self._js("removeThinking()")
|
||||
sources = [
|
||||
{"source": c.get("source", ""), "header_path": c.get("header_path", "")}
|
||||
for c in chunks
|
||||
]
|
||||
self._js(f"addMessage('assistant', {json.dumps(response)}, {json.dumps(sources)})")
|
||||
self._set_enabled(True)
|
||||
|
||||
def _on_error(self, msg: str) -> None:
|
||||
self._js("removeThinking()")
|
||||
self._js(f"addMessage('assistant', {json.dumps('❌ Errore: ' + msg)}, [])")
|
||||
self._set_enabled(True)
|
||||
|
||||
# ── Helpers ───────────────────────────────────────────────────────────────
|
||||
|
||||
def _js(self, script: str) -> None:
|
||||
self._web.page().runJavaScript(script)
|
||||
|
||||
def _set_enabled(self, enabled: bool) -> None:
|
||||
self._input.setEnabled(enabled)
|
||||
self._btn.setEnabled(enabled)
|
||||
if enabled:
|
||||
self._input.setFocus()
|
||||
Reference in New Issue
Block a user