Files
rag-from-scratch/gui/chat_window.py
T
davide ba8035887c chore: rimuove emoji da codice e output terminale
Sostituisce tutti i simboli grafici con testo:
- checkmark/cross -> OK / ERRORE: / AVVISO:
- icone tema GUI -> testo Chiaro / Scuro

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-06-09 09:57:24 +02:00

252 lines
9.4 KiB
Python

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:12px; font-weight:600; padding:4px 8px;",
"icon": "Chiaro",
},
"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:12px; font-weight:600; padding:4px 8px;",
"icon": "Scuro",
},
}
_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(60, 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()