"""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 /.lightrag;\n" "Graphify: graph.json + interactive HTML under /.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 /.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( 'Visit Homepage' ) 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 - dcos.net (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 /.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 "" 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 /.\n" "No vector index runs under this target.", **{t: f"Notes land in /; the {t} index lives\n" f"in /.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 /; 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: / — 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: /.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())