Thicket/thicket/ui_window.py

1185 lines
50 KiB
Python

"""ThicketWindow (QMainWindow) — the main GUI window.
Retro-futuristic MMD3 console (same visual lineage as OpenTranscode):
brushed-aluminum group panels, amber LED path fields, green phosphor
log terminal, rotary knobs for the numeric controls, and a document
queue table that color-codes per-file stage as the ingest proceeds.
Wires together: extractors (per-file text extraction), vault writer
(Obsidian notes), chunker + embedder + Qdrant store (vector index),
the optional LightRAG graph stage, the background environment probe,
and the retrieval strip.
Also exposes ``launch_gui()``, the QApplication entry point invoked
by ``cli.main()`` and ``python -m thicket``.
"""
from __future__ import annotations
from pathlib import Path
from PySide6.QtCore import Qt, Slot
from PySide6.QtGui import QColor, QFont, QPalette
from PySide6.QtWidgets import (
QApplication, QCheckBox, QComboBox, QFileDialog, QGroupBox, QHBoxLayout,
QHeaderView, QLabel, QLineEdit, QMainWindow, QMessageBox,
QPushButton, QSpinBox, QStatusBar, QStyleFactory, QTableWidget,
QTableWidgetItem, QTextEdit, QVBoxLayout, QWidget,
)
from .embedder import EMBEDDING_MODELS
from .env_probe import EnvProbe, PROBED_MODULES
from .extractors import SUPPORTED_EXTENSIONS, scan_files
from .layout import detect_layout, default_input, default_vault
from .pipeline_core import IngestConfig
from .pipeline_worker import PipelineWorker, ProbeWorker, SearchWorker
from .ui_theme import MMD3_QSS
from .widgets import RadioKnob
# Stage status -> LED color (queue table STAGE column)
_STAGE_COLORS: dict[str, str] = {
"QUEUED": "#8a8a8a",
"EXTRACT": "#d4aa50",
"VAULT": "#50b0b0",
"INDEX": "#d4aa50",
"GRAPH": "#d4aa50",
"DONE": "#40d060",
"SKIP": "#666666",
"ERROR": "#e05050",
}
class ThicketWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("Thicket — dcos.net")
self.resize(1150, 940)
self.worker: PipelineWorker | None = None
self.probe_worker: ProbeWorker | None = None
self.search_worker: SearchWorker | None = None
self.ask_worker = None
self.env: EnvProbe | None = None
self._row_for_path: dict[str, int] = {}
self._apply_mmd3_theme()
self._build_ui()
# Probe in the background — service pings + module checks can
# take seconds, and the UI must stay responsive meanwhile.
self._start_probe()
# ── UI Construction ──
def _build_ui(self):
central = QWidget()
central.setObjectName("central")
self.setCentralWidget(central)
root = QVBoxLayout(central)
root.setContentsMargins(10, 6, 10, 4)
root.setSpacing(4)
# ── Header ──
header = QWidget()
header_lay = QVBoxLayout(header)
header_lay.setContentsMargins(0, 0, 0, 0)
header_lay.setSpacing(0)
title = QLabel("Thicket")
title.setFont(QFont("Segoe UI", 22, QFont.Weight.Bold))
title.setAlignment(Qt.AlignmentFlag.AlignCenter)
title.setStyleSheet("color: #d4aa50; letter-spacing: 4px;")
header_lay.addWidget(title)
subtitle = QLabel("dcos.net // super-ingest · rag console")
subtitle.setFont(QFont("Consolas", 8))
subtitle.setAlignment(Qt.AlignmentFlag.AlignCenter)
subtitle.setStyleSheet("color: #555; letter-spacing: 2px;")
header_lay.addWidget(subtitle)
accent = QWidget()
accent.setFixedHeight(1)
accent.setStyleSheet("background: qlineargradient(x1:0, y1:0, x2:1, y2:0,"
"stop:0 transparent, stop:0.15 #d4aa5044,"
"stop:0.5 #d4aa5088, stop:0.85 #d4aa5044, stop:1 transparent);")
header_lay.addWidget(accent)
root.addWidget(header)
# ── Paths ──
# IN is the incoming tree; VAULT is the notes root (and the data
# root for embedded targets); OUT resolves live per TARGET —
# the panel never assumes one destination.
path_grp = QGroupBox("Paths")
path_lay = QVBoxLayout(path_grp)
path_lay.setSpacing(2)
path_lay.setContentsMargins(10, 14, 10, 8)
# Layout-aware defaults: /mnt/AI corpus flow when present,
# home-directory defaults otherwise.
self.in_path_edit = QLineEdit(str(default_input()))
self.vault_path_edit = QLineEdit(str(default_vault()))
for label_text, line_edit, tooltip in [
("IN:", self.in_path_edit,
"Incoming tree — scanned recursively for supported documents.\n"
"Accepts ~ and $VAR references; resolved at ingest time."),
("VAULT:", self.vault_path_edit, ""), # tooltip set per-target
]:
row = QHBoxLayout()
row.setSpacing(6)
lbl = QLabel(label_text)
lbl.setFixedWidth(42)
lbl.setStyleSheet(
"color: #d4aa50; font-family: 'Consolas', monospace; "
"font-weight: bold; font-size: 10px;")
row.addWidget(lbl)
line_edit.setToolTip(tooltip)
row.addWidget(line_edit, 1)
btn_browse = QPushButton("...")
btn_browse.setObjectName("btnBrowse")
btn_browse.setFixedSize(30, 22)
btn_browse.setToolTip("Browse")
btn_browse.clicked.connect(
lambda checked, le=line_edit: self._browse(le)
)
row.addWidget(btn_browse)
path_lay.addLayout(row)
self.vault_lbl = lbl # reframed per target in _on_target_changed
# Resolved destination — recomputed on every target or field edit.
out_row = QHBoxLayout()
out_row.setSpacing(6)
out_lbl = QLabel("OUT:")
out_lbl.setFixedWidth(42)
out_lbl.setStyleSheet(
"color: #d4aa50; font-family: 'Consolas', monospace; "
"font-weight: bold; font-size: 10px;")
out_row.addWidget(out_lbl)
self.out_label = QLabel("")
self.out_label.setToolTip(
"Resolved destination for the current TARGET — updates live\n"
"as the target, vault, notes dir, collection, or host change.")
self.out_label.setTextInteractionFlags(
Qt.TextInteractionFlag.TextSelectableByMouse)
self.out_label.setStyleSheet(
"color: #50b0b0; font-family: 'Consolas', 'DejaVu Sans Mono', "
"monospace; font-size: 10px;")
out_row.addWidget(self.out_label, 1)
path_lay.addLayout(out_row)
root.addWidget(path_grp)
# ── Pipeline stages ──
stage_grp = QGroupBox("Pipeline")
stage_lay = QVBoxLayout(stage_grp)
stage_lay.setSpacing(4)
stage_lay.setContentsMargins(10, 14, 10, 8)
checks_row = QHBoxLayout()
checks_row.setSpacing(16)
self.stage_graph_check = self._stage_check(
"Knowledge graph",
"Extract entities + relationships via the selected graph engine\n"
"(LightRAG: merged entity graph under <vault>/.lightrag;\n"
"Graphify: graph.json + interactive HTML under <vault>/.graphify).\n"
"Ollama LLM, one pass per document (LightRAG) or per batch (Graphify).",
checked=False,
)
checks_row.addWidget(self.stage_graph_check)
self.stage_minio_check = self._stage_check(
"MinIO archive",
"Upload the original source documents to an S3-compatible MinIO\n"
"bucket; the note records s3://bucket/key in its frontmatter.\n"
"Env: MINIO_ENDPOINT / ACCESS_KEY / SECRET_KEY / BUCKET.",
checked=False,
)
checks_row.addWidget(self.stage_minio_check)
checks_row.addStretch(1)
stage_lay.addLayout(checks_row)
# Target row — the vector destination and its connection model.
# Field construction is table-driven: (label, width, field).
from .vector_stores import TARGETS as _VECTOR_TARGETS
self._target_keys = ["obsidian"] + list(_VECTOR_TARGETS.keys())
self._build_target_row_fields()
target_row = QHBoxLayout()
target_row.setSpacing(6)
for label_text, width, field in (
("TARGET", 48, lambda: (self.target_combo, 2)),
("COLLECTION", 76, lambda: (self.collection_edit, 0)),
("HOST", 34, lambda: (self.svc_host_edit, 0)),
("PORT", 30, lambda: (self.svc_port_spin, 0)),
):
lbl = QLabel(label_text)
lbl.setFixedWidth(width)
lbl.setStyleSheet(
"color: #d4aa50; font-family: 'Consolas', monospace; "
"font-weight: bold; font-size: 10px;")
target_row.addWidget(lbl)
widget, stretch = field()
target_row.addWidget(widget, stretch)
target_row.addStretch(1)
stage_lay.addLayout(target_row)
# Connection hint — one line, target-specific, always true.
self.target_hint_label = QLabel("")
self.target_hint_label.setStyleSheet(
"color: #666; font-family: 'Consolas', monospace; font-size: 9px;")
stage_lay.addWidget(self.target_hint_label)
# Model selectors: the embedding model (retrieval-critical,
# tied to collection geometry) and the Ollama LLM (graph stage
# + ask interaction).
models_row = QHBoxLayout()
models_row.setSpacing(8)
embed_lbl = QLabel("EMBED")
embed_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
models_row.addWidget(embed_lbl)
self.embed_combo = QComboBox()
self.embed_combo.addItems(list(EMBEDDING_MODELS.keys()))
self.embed_combo.setToolTip(
"Embedding model for the vector index.\n\n"
"jina-code — English + programming/config corpora; the right\n"
" choice for technical libraries (768-dim).\n"
"bge-base — strongest pure-prose alternative.\n"
"bge-small — light default for small libraries.\n\n"
"Vector dimension is fixed per collection: switching models\n"
"on an existing collection needs a new collection name."
)
self.embed_combo.setFixedHeight(24)
models_row.addWidget(self.embed_combo, 2)
llm_lbl = QLabel("OLLAMA LLM")
llm_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
models_row.addWidget(llm_lbl)
self.llm_combo = QComboBox()
self.llm_combo.setEditable(True)
self.llm_combo.addItem("llama3")
self.llm_combo.setToolTip(
"Ollama chat model driving LightRAG entity extraction.\n"
"Populated from your Ollama server at startup (most sensible\n"
"model pre-selected); editable.\n\n"
"For technical corpora the coder models (qwen3-coder,\n"
"qwen2.5-coder) are strong choices for entity extraction."
)
models_row.addWidget(self.llm_combo, 2)
engine_lbl = QLabel("GRAPH ENGINE")
engine_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
models_row.addWidget(engine_lbl)
self.graph_engine_combo = QComboBox()
self.graph_engine_combo.addItems(["LightRAG", "Graphify"])
self.graph_engine_combo.setToolTip(
"Knowledge-graph engine for the graph stage.\n\n"
"LightRAG — merged entity/relationship graph, one LLM pass\n"
" per document.\n"
"Graphify — stages documents, one batch pass builds graph.json,\n"
" GRAPH_REPORT.md and an interactive graph.html."
)
self.graph_engine_combo.setFixedHeight(24)
models_row.addWidget(self.graph_engine_combo)
models_row.addStretch(1)
stage_lay.addLayout(models_row)
# Options row: filters, notes layout, guards.
opt_row = QHBoxLayout()
opt_row.setSpacing(8)
opt_lbl = QLabel("FILTER")
opt_lbl.setFixedWidth(44)
opt_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
opt_row.addWidget(opt_lbl)
self.ext_edit = QLineEdit(", ".join(sorted(SUPPORTED_EXTENSIONS)))
self.ext_edit.setFixedHeight(22)
self.ext_edit.setToolTip("File extensions to process. Separate with commas.")
opt_row.addWidget(self.ext_edit, 1)
notes_lbl = QLabel("NOTES DIR")
notes_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
opt_row.addWidget(notes_lbl)
self.notes_dir_edit = QLineEdit("Ingested_Brain")
self.notes_dir_edit.setFixedWidth(120)
self.notes_dir_edit.setFixedHeight(22)
self.notes_dir_edit.setToolTip(
"Vault subdirectory for generated notes.")
opt_row.addWidget(self.notes_dir_edit)
maxmb_lbl = QLabel("MAX MB")
maxmb_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
opt_row.addWidget(maxmb_lbl)
self.max_mb_spin = QSpinBox()
self.max_mb_spin.setRange(0, 4096)
self.max_mb_spin.setValue(0)
self.max_mb_spin.setFixedWidth(64)
self.max_mb_spin.setToolTip(
"Skip files larger than this (0 = no limit). Guards against\n"
"accidental disk images / video in the input tree.")
opt_row.addWidget(self.max_mb_spin)
self.skip_unchanged_check = QCheckBox("Skip unchanged")
self.skip_unchanged_check.setToolTip(
"Files whose content hash matches the last ingest are skipped\n"
"(manifest under <vault>/.thicket/). Makes re-running over a\n"
"big library cheap: only new or edited files pay for embedding.")
opt_row.addWidget(self.skip_unchanged_check)
stage_lay.addLayout(opt_row)
guard_row = QHBoxLayout()
guard_row.setSpacing(16)
self.fs_archive_check = QCheckBox("Archive sources (bz2)")
self.fs_archive_check.setToolTip(
"After every stage verifies, move the source out of the incoming\n"
"tree into ingested-archive/ (sibling of IN) and bzip2 it.\n"
"Sources are never deleted — the archive always holds a\n"
"decompressible copy. Skipped files stay in place.")
guard_row.addWidget(self.fs_archive_check)
guard_row.addStretch(1)
stage_lay.addLayout(guard_row)
root.addWidget(stage_grp)
# ── Side panel: compact knobs ──
knobs_panel = QWidget()
knobs_panel.setFixedWidth(170)
knobs_lay = QVBoxLayout(knobs_panel)
knobs_lay.setContentsMargins(6, 8, 6, 8)
knobs_lay.setSpacing(6)
knobs_lay.setAlignment(Qt.AlignmentFlag.AlignTop | Qt.AlignmentFlag.AlignHCenter)
self.chunk_knob = RadioKnob(
min_val=100, max_val=1000, default_val=400,
label="Chunk", unit="wds",
color=(212, 170, 80),
num_ticks=19,
tick_labels=["100", "400", "700", "1000"],
snap_ticks=True,
compact=True,
)
self.chunk_knob.setToolTip(
"Chunk size in words for vector indexing.\n"
"Smaller = sharper retrieval, more points.\n"
"Larger = more context per hit, coarser match."
)
knobs_lay.addWidget(self.chunk_knob, 0, Qt.AlignmentFlag.AlignHCenter)
self.overlap_knob = RadioKnob(
min_val=0, max_val=200, default_val=50,
label="Overlap",
unit="wds",
color=(64, 208, 96),
num_ticks=21,
tick_labels=["0", "100", "200"],
snap_ticks=True,
compact=True,
)
self.overlap_knob.setToolTip(
"Words shared between consecutive chunks.\n"
"Keeps sentences that straddle a boundary\n"
"retrievable from both sides."
)
knobs_lay.addWidget(self.overlap_knob, 0, Qt.AlignmentFlag.AlignHCenter)
self.topk_knob = RadioKnob(
min_val=1, max_val=20, default_val=5,
label="Top-K",
unit="hits",
color=(80, 176, 176),
num_ticks=20,
tick_labels=["1", "10", "20"],
snap_ticks=True,
compact=True,
)
self.topk_knob.setToolTip("Results returned by the retrieval strip.")
knobs_lay.addWidget(self.topk_knob, 0, Qt.AlignmentFlag.AlignHCenter)
# ── Document queue + log (left), knobs (right) ──
left_col = QVBoxLayout()
left_col.setSpacing(4)
self.queue_table = QTableWidget(0, 5)
self.queue_table.setObjectName("queueTable")
self.queue_table.setHorizontalHeaderLabels(
["FILE", "TYPE", "SIZE", "STAGE", "DETAIL"]
)
self.queue_table.verticalHeader().setVisible(False)
self.queue_table.setAlternatingRowColors(True)
self.queue_table.setEditTriggers(QTableWidget.EditTrigger.NoEditTriggers)
self.queue_table.setSelectionBehavior(QTableWidget.SelectionBehavior.SelectRows)
self.queue_table.setWordWrap(False)
hdr = self.queue_table.horizontalHeader()
hdr.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive)
hdr.setSectionResizeMode(1, QHeaderView.ResizeMode.Fixed)
hdr.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed)
hdr.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed)
hdr.setSectionResizeMode(4, QHeaderView.ResizeMode.Stretch)
self.queue_table.setColumnWidth(0, 300)
self.queue_table.setColumnWidth(1, 46)
self.queue_table.setColumnWidth(2, 72)
self.queue_table.setColumnWidth(3, 66)
left_col.addWidget(self.queue_table, 2)
self.log_box = QTextEdit()
self.log_box.setObjectName("logBox")
self.log_box.setReadOnly(True)
left_col.addWidget(self.log_box, 3)
mid_split = QHBoxLayout()
mid_split.setSpacing(6)
mid_split.addLayout(left_col, 1)
mid_split.addWidget(knobs_panel)
root.addLayout(mid_split, 1)
# ── Retrieval strip ──
query_grp = QGroupBox("Retrieval")
query_lay = QHBoxLayout(query_grp)
query_lay.setSpacing(8)
query_lay.setContentsMargins(10, 14, 10, 8)
query_lbl = QLabel("QUERY")
query_lbl.setFixedWidth(44)
query_lbl.setStyleSheet("color: #666; font-size: 7px; letter-spacing: 1px;")
query_lay.addWidget(query_lbl)
self.query_edit = QLineEdit()
self.query_edit.setPlaceholderText("semantic search across ingested chunks...")
self.query_edit.returnPressed.connect(self._search)
query_lay.addWidget(self.query_edit, 1)
self.btn_search = QPushButton(" ? SEARCH")
self.btn_search.setObjectName("btnTeal")
self.btn_search.setFixedHeight(28)
self.btn_search.setToolTip(
"Embed the query locally and search the Qdrant collection.\n"
"Results appear in the log with score, document, and section."
)
self.btn_search.clicked.connect(self._search)
query_lay.addWidget(self.btn_search)
self.btn_ask = QPushButton(" ▣ ASK (SQL)")
self.btn_ask.setObjectName("btnTeal")
self.btn_ask.setFixedHeight(28)
self.btn_ask.setToolTip(
"Natural-language SQL over the corpus via Vanna 2 + Ollama.\n"
"Needs a SQL-backed TARGET (pgvector or mariadb) with data\n"
"ingested. The question runs read-only SELECT queries."
)
self.btn_ask.clicked.connect(self._ask)
query_lay.addWidget(self.btn_ask)
root.addWidget(query_grp)
# ── Status Bar: LED readout ──
self.status = QStatusBar()
self.setStatusBar(self.status)
self.status_label = QLabel(" INITIALIZING...")
self.status_label.setStyleSheet(
"color: #d4aa50; font-family: 'Consolas', 'DejaVu Sans Mono', monospace; "
"font-size: 10px;"
)
self.status.addWidget(self.status_label, 1)
# ── Transport Buttons ──
btn_lay = QHBoxLayout()
btn_lay.setSpacing(8)
self.btn_run = QPushButton(" > INGEST")
self.btn_run.setObjectName("btnRun")
self.btn_run.setFixedHeight(40)
self.btn_run.setEnabled(False)
self.btn_run.clicked.connect(self._start_ingest)
btn_lay.addWidget(self.btn_run)
self.btn_stop = QPushButton(" [] STOP")
self.btn_stop.setObjectName("btnStop")
self.btn_stop.setFixedHeight(40)
self.btn_stop.setEnabled(False)
self.btn_stop.clicked.connect(self._stop_process)
btn_lay.addWidget(self.btn_stop)
self.btn_scan = QPushButton(" ~~ SCAN QUEUE")
self.btn_scan.setObjectName("btnTeal")
self.btn_scan.setFixedHeight(40)
self.btn_scan.setToolTip(
"Preview: scan the input directory and populate the queue table\n"
"without ingesting anything."
)
self.btn_scan.clicked.connect(self._scan)
btn_lay.addWidget(self.btn_scan)
self.btn_about = QPushButton(" ? ABOUT")
self.btn_about.setObjectName("btnTeal")
self.btn_about.setFixedHeight(40)
self.btn_about.setToolTip("About Thicket and its components.")
self.btn_about.clicked.connect(self._show_about)
btn_lay.addWidget(self.btn_about)
root.addLayout(btn_lay)
# ── Footer ──
footer = QWidget()
footer_lay = QHBoxLayout(footer)
footer_lay.setContentsMargins(6, 4, 6, 2)
footer_lay.setSpacing(0)
link_lbl = QLabel(
'<a href="http://git.dcos.net/dcosnet/Thicket" '
'style="color: #888; text-decoration: none;">Visit Homepage</a>'
)
link_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
link_lbl.setOpenExternalLinks(True)
link_lbl.setStyleSheet("font-size: 8px;")
footer_lay.addWidget(link_lbl)
footer_lay.addStretch()
copy_lbl = QLabel(
'AGPL-3.0 | Jeremy Anderson - <a href="http://dcos.net" '
'style="color: #888; text-decoration: none;">dcos.net</a> (c) 2026'
)
copy_lbl.setTextInteractionFlags(Qt.TextInteractionFlag.TextBrowserInteraction)
copy_lbl.setOpenExternalLinks(True)
copy_lbl.setAlignment(Qt.AlignmentFlag.AlignRight)
copy_lbl.setStyleSheet("color: #555; font-size: 8px;")
footer_lay.addWidget(copy_lbl)
root.addWidget(footer)
# Destination resolution is live: target switches, vault,
# notes-dir, collection, host, and port edits all re-resolve.
for edit in (self.vault_path_edit, self.notes_dir_edit,
self.collection_edit, self.svc_host_edit):
edit.textChanged.connect(lambda _t: self._refresh_destination())
self.svc_port_spin.valueChanged.connect(
lambda _v: self._refresh_destination())
# Initialize the target row state (hint line, port, status
# footer). Default destination: qdrant — an indexed brain out
# of the box; obsidian (notes-only) stays one click away.
self.target_combo.setCurrentIndex(self._target_keys.index("qdrant"))
self._on_target_changed(self.target_combo.currentIndex())
def _build_target_row_fields(self) -> None:
"""Construct the four target-row fields; the row layout
composes them from a table."""
self.target_combo = QComboBox()
self.target_combo.addItem("Obsidian vault (notes only)")
from .vector_stores import TARGETS
for spec in TARGETS.values():
self.target_combo.addItem(spec.label)
self.target_combo.setToolTip(
"Vector store destination.\n\n"
"Service targets (qdrant, weaviate) use HOST/PORT.\n"
"Env-driven targets (pgvector, mariadb) read their\n"
" standard environment (see the hint line).\n"
"Embedded targets keep data under <vault>/.thicket/.\n\n"
"Every target shares one payload schema and the same\n"
"exact-replacement re-ingest semantics."
)
self.target_combo.setFixedHeight(24)
self.target_combo.currentIndexChanged.connect(self._on_target_changed)
self.collection_edit = QLineEdit("second_brain")
self.collection_edit.setFixedWidth(150)
self.collection_edit.setToolTip(
"Collection / table name shared by the target.\n"
"Vector dimension is fixed at creation — switching the\n"
"embedding model later needs a fresh collection name."
)
self.svc_host_edit = QLineEdit("localhost")
self.svc_host_edit.setFixedWidth(110)
self.svc_host_edit.setToolTip(
"Service host — applies to service-backed targets only.")
self.svc_port_spin = QSpinBox()
self.svc_port_spin.setRange(1, 65535)
self.svc_port_spin.setValue(6333)
self.svc_port_spin.setFixedWidth(76)
self.svc_port_spin.setToolTip(
"Service port — applies to service-backed targets only.\n"
"qdrant: 6333, weaviate: 8080 (set automatically).")
@staticmethod
def _stage_check(text: str, tooltip: str, checked: bool) -> QCheckBox:
check = QCheckBox(text)
check.setChecked(checked)
check.setToolTip(tooltip)
return check
def _apply_mmd3_theme(self):
self.setStyle(QStyleFactory.create("Fusion"))
self.setStyleSheet(MMD3_QSS)
p = QPalette()
p.setColor(QPalette.ColorRole.Window, QColor(26, 26, 30))
p.setColor(QPalette.ColorRole.WindowText, QColor(200, 200, 200))
p.setColor(QPalette.ColorRole.Base, QColor(20, 20, 24))
p.setColor(QPalette.ColorRole.AlternateBase, QColor(40, 40, 46))
p.setColor(QPalette.ColorRole.ToolTipBase, QColor(30, 30, 36))
p.setColor(QPalette.ColorRole.ToolTipText, QColor(200, 200, 200))
p.setColor(QPalette.ColorRole.Text, QColor(200, 200, 200))
p.setColor(QPalette.ColorRole.Button, QColor(40, 40, 46))
p.setColor(QPalette.ColorRole.ButtonText, QColor(200, 200, 200))
p.setColor(QPalette.ColorRole.Highlight, QColor(212, 170, 80))
p.setColor(QPalette.ColorRole.HighlightedText, QColor(0, 0, 0))
QApplication.instance().setPalette(p)
# ── Slots / helpers ──
@Slot()
def _browse(self, line_edit: QLineEdit):
anchor = str(detect_layout().root) if detect_layout() else ""
path = QFileDialog.getExistingDirectory(
self, "Select Directory", anchor)
if path:
line_edit.setText(path)
def _log(self, msg: str):
# Guard against signals firing during __init__ before log_box
# exists; buffer so nothing is lost.
if not hasattr(self, "log_box") or self.log_box is None:
buffered = getattr(self, "_log_buffer", None)
if buffered is None:
buffered = self._log_buffer = []
buffered.append(msg)
return
buffered = getattr(self, "_log_buffer", None)
if buffered:
for m in buffered:
self.log_box.append(f"> {m}")
self._log_buffer = []
self.log_box.append(f"> {msg}")
sb = self.log_box.verticalScrollBar()
sb.setValue(sb.maximum())
def _parse_extensions(self) -> set[str]:
raw = self.ext_edit.text()
exts = set()
for part in raw.split(","):
part = part.strip().lower()
if not part:
continue
if not part.startswith("."):
part = "." + part
exts.add(part)
return exts or set(SUPPORTED_EXTENSIONS)
# ── Environment probe ──
def _start_probe(self):
host = self.svc_host_edit.text().strip() or "localhost"
self.probe_worker = ProbeWorker(qdrant_host=host,
qdrant_port=self.svc_port_spin.value())
self.probe_worker.log_msg.connect(self._log)
self.probe_worker.probe_done.connect(self._on_probe_done)
self.probe_worker.start()
@Slot(object)
def _on_probe_done(self, env: EnvProbe):
self.env = env
self._log(f"Python {env.python_version}")
missing = [name for name, ok in env.modules.items() if not ok]
for name, role in PROBED_MODULES.items():
ok = env.modules.get(name, False)
self._log(f" {name:14s} {'OK' if ok else 'MISSING':7s} {role}")
if missing:
self._log(
" HINT: pip install 'thicket[ingest]' (parsers+vectors) "
"and/or 'thicket[graph]' (LightRAG)."
)
if env.qdrant_up:
colls = ", ".join(env.qdrant_collections) or "(none yet)"
self._log(f"Qdrant: UP at {self.svc_host_edit.text()}:"
f"{self.svc_port_spin.value()} — collections: {colls}")
else:
self._log(f"Qdrant: DOWN ({env.qdrant_error or 'no response'}). "
f"Start it with: docker run -p 6333:6333 qdrant/qdrant")
if env.ollama_up:
if env.ollama_models:
self.llm_combo.clear()
self.llm_combo.addItems(env.ollama_models)
picked = self._preferred_llm(env.ollama_models)
self.llm_combo.setCurrentText(picked)
self._log(f"Ollama: UP — graph LLM pre-selected: {picked}")
shown = env.ollama_models[:12]
extra = len(env.ollama_models) - len(shown)
models = ", ".join(shown) + (f" … (+{extra} more)" if extra > 0 else "")
self._log(f"Ollama models: {models or '(none pulled)'}")
else:
self._log(f"Ollama: DOWN ({env.ollama_error or 'no response'}) — "
f"LightRAG stage unavailable.")
pg_state = ("UP" if env.pg_up
else f"DOWN ({env.pg_error or 'no response'}) — set PG* env")
self._log(f"Postgres: {pg_state}")
minio_state = ("UP" if env.minio_up
else f"DOWN ({env.minio_error or 'no response'}) — "
f"set MINIO_* env")
self._log(f"MinIO: {minio_state}")
# AI filesystem layout — what Thicket adopted as defaults.
profile = detect_layout()
if profile:
self._log(f"layout: {profile.root} detected — "
f"IN={profile.corpus_cold}, vault={profile.corpus_hot}"
+ (", books library present" if profile.books_present else ""))
else:
self._log("layout: /mnt/AI not found — home-directory defaults.")
# Vector target availability — one line per registry entry.
from .vector_stores import TARGETS
for spec in TARGETS.values():
state = "ready" if env.vector_ready(spec.key) else "not available"
self._log(f" vector target {spec.key:8s} {state}")
# Vault notes are the always-on core stage; slugify is its one
# dependency. INGEST stays available (other stages validate at
# click time) but the warning is loud.
if not env.vault_ready:
self._log("WARNING: notes cannot be written "
"(python-slugify missing — pip install 'thicket[ingest]').")
self.btn_run.setEnabled(True)
self.btn_run.setText(" > INGEST")
qdrant_txt = "UP" if env.qdrant_up else "DOWN"
ollama_txt = "UP" if env.ollama_up else "DOWN"
fast_txt = "OK" if env.modules.get("fastembed") else "MISSING"
self._probe_summary = (
f"Qdrant: {qdrant_txt} | Ollama: {ollama_txt} | fastembed: {fast_txt}"
)
self._refresh_status_target()
def _current_target(self) -> str:
idx = self.target_combo.currentIndex()
return self._target_keys[idx] if 0 <= idx < len(self._target_keys) else "qdrant"
# Default port per service-backed target; env-driven connection
# hints for the rest.
_TARGET_PORTS = {"qdrant": 6333, "weaviate": 8080}
_ENV_HINTS = {
"pgvector": "env: PGHOST / PGPORT / PGUSER / PGPASSWORD / PGDATABASE (or PGDSN)",
"mariadb": ("env: MARIADB_HOST / MARIADB_PORT / MARIADB_USER / "
"MARIADB_PASSWORD / MARIADB_DATABASE"),
}
# Embedded targets keep their index inside the vault tree.
_EMBEDDED_DATA = ("chroma", "lancedb", "faiss", "milvus", "duckdb",
"sqlitevec")
def _refresh_destination(self) -> None:
"""Resolve the true destination for the current TARGET from the
live field values — the Paths panel never assumes one output."""
import os
target = self._current_target()
vault = self.vault_path_edit.text().strip() or "<vault>"
notes = self.notes_dir_edit.text().strip() or "Ingested_Brain"
collection = self.collection_edit.text().strip() or "second_brain"
host = self.svc_host_edit.text().strip() or "localhost"
port = self.svc_port_spin.value()
notes_txt = f"notes: {vault}/{notes}/"
match target:
case "obsidian":
out = f"{vault}/{notes}/"
case t if t in self._EMBEDDED_DATA:
out = f"{vault}/.thicket/{t}/ ({notes_txt})"
case "qdrant":
out = f"qdrant://{host}:{port}/{collection} ({notes_txt})"
case "weaviate":
out = (f"weaviate://{host}:{port}+grpc50051/"
f"{collection} ({notes_txt})")
case "pgvector":
pg_host = os.environ.get("PGHOST", "localhost")
pg_db = os.environ.get("PGDATABASE", "thicket")
out = (f"postgres://{pg_host}/{pg_db} \u00b7 \"{collection}\""
f" ({notes_txt})")
case "mariadb":
my_host = os.environ.get("MARIADB_HOST", "127.0.0.1")
my_db = os.environ.get("MARIADB_DATABASE", "thicket")
out = (f"mariadb://{my_host}/{my_db} \u00b7 `{collection}`"
f" ({notes_txt})")
case _:
out = target
self.out_label.setText(out)
# Reframe the vault field for what it means under this target.
vault_tooltips = {
"obsidian":
"THE destination: notes land in <vault>/<notes dir>.\n"
"No vector index runs under this target.",
**{t: f"Notes land in <vault>/<notes dir>; the {t} index lives\n"
f"in <vault>/.thicket/{t}/ — the whole brain is one tree."
for t in self._EMBEDDED_DATA},
}
self.vault_path_edit.setToolTip(vault_tooltips.get(
target,
f"Notes land in <vault>/<notes dir>; the index lives in the "
f"{target} service, not in the vault."))
def _target_hint(self, target: str) -> str:
"""One truthful line about how the target connects."""
if target == "obsidian":
return "notes only: <vault>/<notes dir> — no vector index (SEARCH/ASK disabled)"
if target in self._ENV_HINTS:
return self._ENV_HINTS[target]
if target in self._TARGET_PORTS:
return (f"service: http://{self.svc_host_edit.text()}:"
f"{self.svc_port_spin.value()} (container)")
return f"embedded: <vault>/.thicket/{target}/ — no service needed"
@Slot()
def _on_target_changed(self, _index: int):
"""Target switch re-scores the whole connection row: host/port
live only where they apply (with the right default port), the
hint line restates how this target connects, and the status
footer carries the new target immediately."""
target = self._current_target()
service_port = self._TARGET_PORTS.get(target)
self.svc_host_edit.setEnabled(service_port is not None)
self.svc_port_spin.setEnabled(service_port is not None)
if service_port is not None:
self.svc_port_spin.setValue(service_port)
self.target_hint_label.setText(f" {self._target_hint(target)}")
self._refresh_destination()
self._refresh_status_target()
def _refresh_status_target(self) -> None:
"""Status footer: current target + last probe summary. Called
on probe completion and on every target change."""
target_txt = f"target: {self._current_target()}"
summary = getattr(self, "_probe_summary", None)
self.status_label.setText(
f"{target_txt} | {summary}" if summary else target_txt)
def _vector_block_reason(self, target: str) -> str:
"""Decisive reason string for a blocked vector target."""
from .vector_stores import TARGETS
spec = TARGETS.get(target)
if spec is None:
return f"unknown target '{target}'"
if self.env is not None and spec.service == "qdrant" and not self.env.qdrant_up:
return f"Qdrant service unreachable at " \
f"{self.svc_host_edit.text()}:{self.svc_port_spin.value()}"
missing = [m for m in spec.modules if not self.env.modules.get(m, False)] \
if self.env else list(spec.modules)
return f"missing modules: {', '.join(missing)}"
@staticmethod
def _preferred_llm(models: list[str]) -> str:
"""Step-down auto-pick for the graph LLM: first preference
present on the server wins; fall to the first llama-family
model, then to the first model listed."""
preferences = ("llama3.1:latest", "qwen3-coder:30b",
"qwen2.5-coder:32b", "llama3.3:latest",
"mistral:latest", "qwen3:latest")
exact = next((m for m in preferences if m in models), None)
if exact is not None:
return exact
llama_family = next((m for m in models if "llama" in m.lower()), None)
return llama_family or models[0]
# ── Queue table ──
def _populate_queue(self, files: list[Path]):
self.queue_table.setRowCount(0)
self._row_for_path.clear()
for row, filepath in enumerate(files):
self.queue_table.insertRow(row)
try:
size = filepath.stat().st_size
if size < 1_048_576:
size_txt = f"{size / 1024:.0f}K"
else:
size_txt = f"{size / 1_048_576:.1f}M"
except OSError:
size_txt = "?"
for col, text in (
(0, filepath.name), (1, filepath.suffix.lstrip(".").upper()),
(2, size_txt), (3, "QUEUED"), (4, ""),
):
item = QTableWidgetItem(text)
if col == 3:
item.setForeground(QColor(_STAGE_COLORS["QUEUED"]))
self.queue_table.setItem(row, col, item)
self._row_for_path[str(filepath)] = row
self.queue_table.sortItems(0)
@Slot()
def _scan(self):
in_dir = Path(self.in_path_edit.text()).expanduser()
if not in_dir.is_dir():
self._log(f"ERROR: Input directory does not exist: {in_dir}")
return
files = scan_files(in_dir, self._parse_extensions())
self._populate_queue(files)
self._log(f"Scan: {len(files)} eligible document(s) in '{in_dir}'")
@Slot(str, str)
def _on_file_status(self, path: str, status: str):
row = self._row_for_path.get(path)
if row is None:
return
item = self.queue_table.item(row, 3)
if item is None:
return
item.setText(status)
item.setForeground(QColor(_STAGE_COLORS.get(status, "#c8c8c8")))
@Slot(str, str)
def _on_file_detail(self, path: str, detail: str):
row = self._row_for_path.get(path)
if row is None:
return
item = self.queue_table.item(row, 4)
if item is not None:
item.setText(detail)
# ── Process control ──
@Slot()
def _start_ingest(self):
if self.worker is not None and self.worker.isRunning():
return
in_dir = Path(self.in_path_edit.text()).expanduser()
vault_dir = Path(self.vault_path_edit.text()).expanduser()
if not in_dir.is_dir():
self._log(f"ERROR: Input directory does not exist: {in_dir}")
return
if not vault_dir.is_dir():
reply = QMessageBox.question(
self, "Create Vault Directory",
f"Obsidian vault path does not exist:\n{vault_dir}\n\nCreate it?",
QMessageBox.StandardButton.Yes | QMessageBox.StandardButton.No,
QMessageBox.StandardButton.Yes,
)
if reply != QMessageBox.StandardButton.Yes:
self._log("Cancelled: vault directory not confirmed.")
return
vault_dir.mkdir(parents=True, exist_ok=True)
self._log(f"Created vault directory: {vault_dir}")
target = self._current_target()
use_vault = True # notes are the product
use_vector = target != "obsidian" # a vector TARGET adds indexing
use_graph = self.stage_graph_check.isChecked()
use_minio = self.stage_minio_check.isChecked()
if not (use_vault or use_vector or use_graph or use_minio
or self.fs_archive_check.isChecked()):
self._log("ERROR: Nothing to do — pick a destination or a stage.")
return
# Readiness table: (enabled, ready, reason, dialog title, hint).
# The first enabled-but-not-ready stage blocks the run.
readiness = (
(
use_vector,
self.env.vector_ready(target) if self.env else True,
self._vector_block_reason(target),
"Vector Target Unavailable",
"Pick another TARGET or fix the environment.",
),
(
use_graph,
self.env.graph_ready if self.env else True,
"Ollama service unreachable"
if self.env and not self.env.ollama_up
else "lightrag not installed (pip install 'thicket[graph]')",
"LightRAG Stage Unavailable",
"Uncheck it or fix the environment.",
),
)
if use_minio and self.env is not None and not self.env.minio_up:
readiness += ((
True, False,
f"MinIO unreachable ({self.env.minio_error or 'no response'})",
"MinIO Archive Unavailable",
"Uncheck it, start MinIO, or fix MINIO_* env vars.",
),)
blocker = next((g for g in readiness if g[0] and not g[1]), None)
if blocker is not None:
_, _, reason, title, hint = blocker
self._log(f"ERROR: {title} — {reason}. See probe log above.")
QMessageBox.warning(self, title,
f"The stage cannot run: {reason}.\n\n{hint}")
return
config = IngestConfig(
input_dir=in_dir,
vault_dir=vault_dir,
qdrant_host=self.svc_host_edit.text().strip() or "localhost",
qdrant_port=self.svc_port_spin.value(),
collection=self.collection_edit.text().strip() or "second_brain",
embed_model=self.embed_combo.currentText(),
chunk_size=self.chunk_knob.intValue(),
overlap=self.overlap_knob.intValue(),
use_vault=use_vault,
target=target if use_vector else "obsidian",
use_qdrant=use_vector,
use_lightrag=use_graph,
ollama_llm=self.llm_combo.currentText().strip() or "llama3",
)
# Sync the queue table with what the worker is about to process.
self._populate_queue(scan_files(in_dir, self._parse_extensions()))
self.worker = PipelineWorker(config)
self.worker.log_msg.connect(self._log)
self.worker.file_status.connect(self._on_file_status)
self.worker.file_detail.connect(self._on_file_detail)
self.worker.progress_msg.connect(self._on_progress)
self.worker.finished_queue.connect(self._on_finished)
self.btn_run.setEnabled(False)
self.btn_run.setText("RUNNING...")
self.btn_stop.setEnabled(True)
self.btn_scan.setEnabled(False)
self.btn_search.setEnabled(False)
self.worker.start()
@Slot(str, int, int)
def _on_progress(self, filename: str, current: int, total: int):
self.status_label.setText(f"Ingesting {current}/{total}: {filename}")
@Slot(int, int)
def _on_finished(self, ok: int, fail: int):
self.btn_run.setEnabled(True)
self.btn_run.setText(" > INGEST")
self.btn_stop.setEnabled(False)
self.btn_scan.setEnabled(True)
self.btn_search.setEnabled(True)
self.status_label.setText(f"Done — {ok} succeeded, {fail} failed")
self._log(f"Queue finished: {ok} succeeded, {fail} failed.")
@Slot()
def _stop_process(self):
if self.worker is not None and self.worker.isRunning():
self._log("STOP: Exiting queue after current file finishes...")
self.worker.stop()
self.btn_stop.setEnabled(False)
# ── Retrieval ──
@Slot()
def _search(self):
if self.search_worker is not None and self.search_worker.isRunning():
return
if self._current_target() == "obsidian":
self._log("SEARCH: TARGET is Obsidian vault (notes only) — "
"pick a vector target to search.")
return
query = self.query_edit.text().strip()
if not query:
self._log("SEARCH: empty query — type something first.")
return
self.search_worker = SearchWorker(
query=query,
vault_dir=Path(self.vault_path_edit.text()).expanduser(),
target=self._current_target(),
host=self.svc_host_edit.text().strip() or "localhost",
port=self.svc_port_spin.value(),
collection=self.collection_edit.text().strip() or "second_brain",
embed_model=self.embed_combo.currentText(),
top_k=self.topk_knob.intValue(),
)
self.search_worker.log_msg.connect(self._log)
self.search_worker.search_done.connect(self._on_search_done)
self.btn_search.setEnabled(False)
self._log(f"SEARCH: '{query}'")
self.search_worker.start()
@Slot(int)
def _on_search_done(self, count: int):
self.btn_search.setEnabled(True)
if count > 0:
self.status_label.setText(f"Search: {count} hit(s)")
@Slot()
def _ask(self):
"""Natural-language SQL over the corpus (SQL-backed targets)."""
if self.ask_worker is not None and self.ask_worker.isRunning():
return
question = self.query_edit.text().strip()
if not question:
self._log("ASK: empty query — type a question first.")
return
from .ask_vanna import SQL_TARGETS
target = self._current_target()
if target == "obsidian":
self._log("ASK: TARGET is Obsidian vault (notes only) — "
"pick a SQL target (pgvector / mariadb) to ask.")
return
if target not in SQL_TARGETS:
self._log(
f"ASK: target '{target}' has no SQL corpus — ask works with "
f"{', '.join(SQL_TARGETS)} (switch TARGET)."
)
return
from .pipeline_worker import AskWorker
self.ask_worker = AskWorker(
question, target=target,
collection=self.collection_edit.text().strip() or "second_brain",
llm_model=self.llm_combo.currentText().strip() or "llama3",
)
self.ask_worker.log_msg.connect(self._log)
self.ask_worker.ask_done.connect(
lambda ok: self.btn_ask.setEnabled(True))
self.btn_ask.setEnabled(False)
self._log(f"ASK ({target}): '{question}'")
self.ask_worker.start()
# ── About ──
def _show_about(self):
from . import __version__
dlg = QMessageBox(self)
dlg.setWindowTitle("About Thicket")
dlg.setText(
f"Thicket v{__version__} — super-ingest + RAG console\n\n"
"Feed it documents; it grows a thicket: dense, interconnected,\n"
"searchable.\n\n"
"Destinations: Obsidian vault (notes only) or any of ten\n"
"open-source vector stores — qdrant, chroma, lancedb, faiss,\n"
"milvus, weaviate, pgvector, duckdb, sqlite-vec, mariadb —\n"
"with identical payloads and scores across all of them.\n\n"
"Optional stages: knowledge graphs (LightRAG or Graphify via\n"
"Ollama), MinIO object archive, and a bz2 source archive —\n"
"sources are never deleted. Ask questions in natural language\n"
"over SQL-backed corpora (Vanna 2 + Ollama).\n\n"
"Code-aware ingestion: fenced blocks stay atomic with their\n"
"language tags; source and config files ingest as listings.\n"
"Detects the /mnt/AI corpus layout (cold in, hot out)."
)
dlg.setInformativeText(
"Components (invoked, not bundled): PySide6 (LGPL-3.0), pypdf (BSD),\n"
"EbookLib (AGPL-3.0), BeautifulSoup (MIT), python-slugify (MIT),\n"
"FastEmbed (Apache-2.0), Qdrant / Chroma / LanceDB / pymilvus /\n"
"MinIO client (Apache-2.0), FAISS / PyMySQL / sqlite-vec / LightRAG /\n"
"Vanna / Ollama (MIT), weaviate-client (BSD-3), psycopg (LGPL-3.0),\n"
"Graphify (Apache-2.0/MIT).\n\n"
"Thicket itself: AGPL-3.0 — Jeremy Anderson, info@dcos.net, dcos.net, 2026."
)
dlg.setStandardButtons(QMessageBox.StandardButton.Ok)
dlg.exec()
# ──────────────────────────────────────────────
# GUI ENTRY POINT
# ──────────────────────────────────────────────
def launch_gui(argv: list[str] | None = None) -> int:
"""Create the QApplication, show the ThicketWindow, run the Qt
event loop. Returns the Qt event-loop exit code (0 on clean
shutdown)."""
import sys
app = QApplication(sys.argv if argv is None else argv)
window = ThicketWindow()
window.show()
return sys.exit(app.exec())