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:
2026-06-09 09:50:37 +02:00
parent 619e110cd5
commit f947131f16
5 changed files with 598 additions and 0 deletions
View File
+271
View File
@@ -0,0 +1,271 @@
<!DOCTYPE html>
<html lang="it">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.css">
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/katex.min.js"></script>
<script defer src="https://cdn.jsdelivr.net/npm/katex@0.16.11/dist/contrib/auto-render.min.js"
onload="renderMathInElement(document.body, {delimiters: KATEX_DELIMITERS, throwOnError: false})"></script>
<script src="https://cdn.jsdelivr.net/npm/marked@13.0.2/marked.min.js"></script>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
/* ── Temi ─────────────────────────────────────────────────────── */
:root {
--bg: #212121;
--text: #ececec;
--bubble-user: #2f2f2f;
--code-bg: #1a1a1a;
--code-border: #333;
--inline-code-bg: #2a2a2a;
--table-border: #3a3a3a;
--table-th: #2a2a2a;
--table-even: #252525;
--source-border: #2e2e2e;
--source-color: #6e6e80;
--chip-bg: #2a2a2a;
--chip-border: #333;
--src-doc: #888;
--src-path: #aaa;
--dot-color: #6e6e80;
--scrollbar-track: #1a1a1a;
--scrollbar-thumb: #3a3a3a;
}
body.light {
--bg: #ffffff;
--text: #1a1a1a;
--bubble-user: #efefef;
--code-bg: #f5f5f5;
--code-border: #ddd;
--inline-code-bg: #ebebeb;
--table-border: #e0e0e0;
--table-th: #f0f0f0;
--table-even: #fafafa;
--source-border: #e8e8e8;
--source-color: #888;
--chip-bg: #f5f5f5;
--chip-border: #ddd;
--src-doc: #666;
--src-path: #444;
--dot-color: #aaa;
--scrollbar-track: #f0f0f0;
--scrollbar-thumb: #ccc;
}
/* ── Base ─────────────────────────────────────────────────────── */
body {
background: var(--bg);
color: var(--text);
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
font-size: 15px;
line-height: 1.65;
transition: background 0.2s, color 0.2s;
}
::-webkit-scrollbar { width: 6px; }
::-webkit-scrollbar-track { background: var(--scrollbar-track); }
::-webkit-scrollbar-thumb { background: var(--scrollbar-thumb); border-radius: 3px; }
#chat {
max-width: 820px;
margin: 0 auto;
padding: 24px 20px 40px;
}
.message { margin: 20px 0; }
/* ── Utente ────────────────────────────────────────────────────── */
.user { display: flex; justify-content: flex-end; }
.user .bubble {
background: var(--bubble-user);
border-radius: 18px 18px 4px 18px;
padding: 10px 16px;
max-width: 72%;
white-space: pre-wrap;
word-break: break-word;
}
/* ── Assistente ────────────────────────────────────────────────── */
.assistant .bubble { max-width: 100%; }
.assistant .bubble p { margin: 0.5em 0; }
.assistant .bubble p:first-child { margin-top: 0; }
.assistant .bubble p:last-child { margin-bottom: 0; }
.assistant .bubble h1,
.assistant .bubble h2,
.assistant .bubble h3 { margin: 1em 0 0.4em; font-weight: 600; }
.assistant .bubble ol,
.assistant .bubble ul { padding-left: 1.4em; margin: 0.5em 0; }
.assistant .bubble li { margin: 0.25em 0; }
.assistant .bubble pre {
background: var(--code-bg);
border: 1px solid var(--code-border);
border-radius: 8px;
padding: 14px 16px;
overflow-x: auto;
margin: 0.8em 0;
}
.assistant .bubble code {
font-family: 'JetBrains Mono', 'Fira Code', 'Consolas', monospace;
font-size: 0.88em;
}
.assistant .bubble :not(pre) > code {
background: var(--inline-code-bg);
border-radius: 4px;
padding: 2px 5px;
}
.assistant .bubble table {
border-collapse: collapse;
width: 100%;
margin: 0.8em 0;
font-size: 0.92em;
}
.assistant .bubble th,
.assistant .bubble td {
border: 1px solid var(--table-border);
padding: 7px 12px;
text-align: left;
}
.assistant .bubble th { background: var(--table-th); font-weight: 600; }
.assistant .bubble tr:nth-child(even) td { background: var(--table-even); }
.assistant .bubble .katex-display {
overflow-x: auto;
overflow-y: hidden;
padding: 6px 0;
}
/* ── Fonti ─────────────────────────────────────────────────────── */
.sources {
margin-top: 10px;
padding-top: 8px;
border-top: 1px solid var(--source-border);
font-size: 0.78em;
color: var(--source-color);
}
.source-item {
display: inline-block;
background: var(--chip-bg);
border: 1px solid var(--chip-border);
border-radius: 6px;
padding: 2px 8px;
margin: 2px 4px 2px 0;
cursor: default;
}
.source-item .src-doc { color: var(--src-doc); }
.source-item .src-path { color: var(--src-path); }
/* ── Thinking ──────────────────────────────────────────────────── */
.thinking .bubble { display: flex; align-items: center; gap: 5px; height: 28px; }
.thinking .dot {
width: 7px; height: 7px;
background: var(--dot-color);
border-radius: 50%;
animation: bounce 1.3s infinite ease-in-out;
}
.thinking .dot:nth-child(2) { animation-delay: 0.2s; }
.thinking .dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes bounce {
0%, 80%, 100% { transform: translateY(0); opacity: 0.4; }
40% { transform: translateY(-6px); opacity: 1; }
}
</style>
</head>
<body>
<div id="chat"></div>
<script>
const KATEX_DELIMITERS = [
{left: "$$", right: "$$", display: true},
{left: "\\[", right: "\\]", display: true},
{left: "$", right: "$", display: false},
{left: "\\(", right: "\\)", display: false}
];
marked.setOptions({ breaks: true, gfm: true });
function setTheme(theme) {
document.body.classList.toggle('light', theme === 'light');
}
function addMessage(role, content, sources) {
const chat = document.getElementById('chat');
const wrap = document.createElement('div');
wrap.className = 'message ' + role;
const bubble = document.createElement('div');
bubble.className = 'bubble';
if (role === 'user') {
bubble.textContent = content;
} else {
bubble.innerHTML = marked.parse(content);
if (sources && sources.length > 0) {
const srcDiv = document.createElement('div');
srcDiv.className = 'sources';
const seen = new Set();
sources.forEach(s => {
const key = s.source + '|' + s.header_path;
if (seen.has(key)) return;
seen.add(key);
const chip = document.createElement('span');
chip.className = 'source-item';
const doc = s.source || '';
const path = s.header_path || '';
chip.innerHTML = path
? `<span class="src-doc">${esc(doc)}</span> <span class="src-path">${esc(path)}</span>`
: `<span class="src-doc">${esc(doc)}</span>`;
srcDiv.appendChild(chip);
});
bubble.appendChild(srcDiv);
}
renderMathInElement(bubble, { delimiters: KATEX_DELIMITERS, throwOnError: false });
}
wrap.appendChild(bubble);
chat.appendChild(wrap);
window.scrollTo(0, document.body.scrollHeight);
}
function addThinking() {
const chat = document.getElementById('chat');
const wrap = document.createElement('div');
wrap.id = 'thinking-msg';
wrap.className = 'message assistant thinking';
const bubble = document.createElement('div');
bubble.className = 'bubble';
[1,2,3].forEach(() => {
const d = document.createElement('div');
d.className = 'dot';
bubble.appendChild(d);
});
wrap.appendChild(bubble);
chat.appendChild(wrap);
window.scrollTo(0, document.body.scrollHeight);
}
function removeThinking() {
const el = document.getElementById('thinking-msg');
if (el) el.remove();
}
function esc(s) {
return s.replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');
}
</script>
</body>
</html>
+251
View File
@@ -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()
+48
View File
@@ -0,0 +1,48 @@
#!/usr/bin/env python3
"""
RAG Assistant — GUI desktop.
Uso:
python gui/main.py
python gui/main.py --collection matematica
"""
import argparse
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from PySide6.QtWidgets import QApplication
from PySide6.QtGui import QPalette, QColor
from gui.chat_window import ChatWindow
def main() -> None:
parser = argparse.ArgumentParser(description="RAG Assistant GUI")
parser.add_argument("--collection", default="", help="Collection ChromaDB da aprire")
args = parser.parse_args()
app = QApplication(sys.argv)
app.setApplicationName("RAG Assistant")
app.setStyle("Fusion")
# Palette scura per i widget nativi Qt
palette = QPalette()
palette.setColor(QPalette.ColorRole.Window, QColor("#212121"))
palette.setColor(QPalette.ColorRole.WindowText, QColor("#ececec"))
palette.setColor(QPalette.ColorRole.Base, QColor("#2a2a2a"))
palette.setColor(QPalette.ColorRole.AlternateBase, QColor("#1a1a1a"))
palette.setColor(QPalette.ColorRole.Text, QColor("#ececec"))
palette.setColor(QPalette.ColorRole.Button, QColor("#2a2a2a"))
palette.setColor(QPalette.ColorRole.ButtonText, QColor("#ececec"))
palette.setColor(QPalette.ColorRole.Highlight, QColor("#10a37f"))
palette.setColor(QPalette.ColorRole.HighlightedText, QColor("#ffffff"))
app.setPalette(palette)
window = ChatWindow(default_collection=args.collection)
window.show()
sys.exit(app.exec())
if __name__ == "__main__":
main()
+28
View File
@@ -0,0 +1,28 @@
from __future__ import annotations
import sys
from pathlib import Path
sys.path.insert(0, str(Path(__file__).parent.parent))
from PySide6.QtCore import QThread, Signal
import chromadb
class RagWorker(QThread):
finished = Signal(str, list) # response, chunks
error = Signal(str)
def __init__(self, question: str, collection: chromadb.Collection, parent=None):
super().__init__(parent)
self.question = question
self.collection = collection
def run(self) -> None:
try:
from rag.rag import retrieve, build_prompt, call_ollama
chunks = retrieve(self.collection, self.question)
system, prompt = build_prompt(self.question, chunks)
response = call_ollama(prompt, system=system)
self.finished.emit(response, chunks)
except Exception as exc:
self.error.emit(str(exc))