1185 lines
50 KiB
Python
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())
|