"""Structure (RAG) tab — knowledge graph of code / document structure. Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates when idle and opens a node's storage folder on click. If WebEngine isn't available (e.g. the standalone .exe), a native draggable QGraphicsView is the in-app fallback. The graph auto-updates when the Code agent produces output, and an Agent box on the right answers questions over the graph (Graph-RAG). """ from __future__ import annotations from .graph_qa_widget import GraphQaMixin from .graph_project import GraphProjectMixin from .graph_web import _HAS_WEB, QWebChannel, QWebEngineView, _frozen_onefile from .graph_render import GraphRenderMixin from .graph_scene import _Edge, _GraphView, _Node import re import sys from pathlib import Path from PySide6.QtCore import QPointF, Qt, QTimer, Signal from PySide6.QtGui import QColor from PySide6.QtWidgets import QComboBox, QFileDialog, QGraphicsScene, QGraphicsView, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar, QTextBrowser, QVBoxLayout, QWidget from ...theme import current_palette from ...core.worker import AgentWorker from ...i18n import on_language_changed, tr from ...state import AppContext from ...ui.icons import collapse_right_icon, icon from ...ui.widgets import CollapseStrip try: from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available except Exception: pass class StructureGraphView(GraphQaMixin, GraphRenderMixin, GraphProjectMixin, QWidget): status_message = Signal(str) def __init__(self, ctx: AppContext): super().__init__() self.ctx = ctx self._worker: AgentWorker | None = None self._node_items: list[_Node] = [] self._edge_items: list[_Edge] = [] self._centroid = QPointF(0, 0) self._link = 120 self._graph = None self._needs_scan = False self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) self._ask_worker: AgentWorker | None = None self._answer = "" self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows # TEMPORARY extracted file content for Q&A (real content, not just the # graph structure). Kept only while this tab is shown — cleared on leaving # the tab or switching project/root (see _clear_extracts / hideEvent). self._extract_cache: dict = {} # path -> extracted text self._extract_dir = None # temp folder for md/json dumps self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox self._rescan_timer = QTimer(self) self._rescan_timer.setSingleShot(True) self._rescan_timer.setInterval(1500) self._rescan_timer.timeout.connect(self._scan) root = QVBoxLayout(self) bar = QHBoxLayout() self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) self._pick_btn = QPushButton() self._pick_btn.setIcon(icon("folder")) self._pick_btn.setObjectName("primary") self._pick_btn.clicked.connect(self._pick) self.project_combo = QComboBox() self.project_combo.currentIndexChanged.connect(self._on_project_changed) self._scan_btn = QPushButton() self._scan_btn.setIcon(icon("search")) self._scan_btn.setObjectName("primary") self._scan_btn.clicked.connect(self._scan) # ONE toolbar row. There used to be a second row holding just the # messages toggle and Export, which cost a whole row of height to carry # two buttons. self._export_btn = QPushButton() self._export_btn.setIcon(icon("upload")) self._export_btn.setObjectName("primary") self._export_btn.clicked.connect(self._export) bar.addWidget(self.path_edit, 1) bar.addWidget(self._pick_btn) bar.addWidget(self.project_combo) bar.addWidget(self._scan_btn) bar.addWidget(self._export_btn) root.addLayout(bar) self._refresh_project_combo() # Đồ thị | Tin nhắn as a real pair of tabs: the old single button # relabelled itself, so the view you were NOT looking at was the only # one named on screen. self.view_tabs = QTabBar() self.view_tabs.setObjectName("viewTabs") self.view_tabs.setDrawBase(False) self.view_tabs.setExpanding(False) self.view_tabs.addTab(icon("graph"), "") self.view_tabs.addTab(icon("message"), "") self.view_tabs.currentChanged.connect(self._on_view_tab) tab_row = QHBoxLayout() tab_row.setContentsMargins(0, 0, 0, 0) tab_row.addWidget(self.view_tabs) tab_row.addStretch(1) root.addLayout(tab_row) split = QSplitter(Qt.Horizontal) self.scene = QGraphicsScene() self.scene.setBackgroundBrush(QColor(current_palette().bg)) self.scene.selectionChanged.connect(self._on_selection) self.view = _GraphView(self.scene) self._stack = QStackedWidget() self._stack.addWidget(self.view) # A "Messages" view: all conversation messages grouped BY DAY, shown as # JSON — a plain tree switched in via setCurrentWidget (never touches the # D3/WebEngine graph). Populated from the (project-scoped) history store. from PySide6.QtWidgets import QTreeWidget self._msgs_view = QTreeWidget() self._msgs_view.setHeaderHidden(True) self._msgs_view.itemClicked.connect(self._show_msg_json) self._stack.addWidget(self._msgs_view) self.web = None self._bridge = None self._channel = None # The legend + Show-relationship control live INSIDE the D3 graph # template now (assets/graph_template.html) — the graph column is just # the stack (native view / D3 web / messages). split.addWidget(self._stack) # Right-side agent panel (GraphRAG Q&A) right = QWidget() rl = QVBoxLayout(right) rl.setContentsMargins(0, 0, 0, 0) # Agent panel header with collapse button ag_hdr = QHBoxLayout() self._ag_collapse = QPushButton() self._ag_collapse.setIcon(collapse_right_icon()) self._ag_collapse.setFixedWidth(28) self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True)) self._ag_label = QLabel() ag_hdr.addWidget(self._ag_collapse) ag_hdr.addWidget(self._ag_label, 1) rl.addLayout(ag_hdr) # Ask row ask_row = QHBoxLayout() self.ask_edit = QLineEdit() self.ask_edit.returnPressed.connect(self._ask) self._ask_btn = QPushButton() self._ask_btn.setIcon(icon("chat")) self._ask_btn.setObjectName("primary") self._ask_btn.clicked.connect(self._ask) ask_row.addWidget(self.ask_edit, 1) ask_row.addWidget(self._ask_btn) rl.addLayout(ask_row) # Detail browser self.detail = QTextBrowser() self.detail.setReadOnly(True) self.detail.setOpenLinks(False) self.detail.anchorClicked.connect(self._on_detail_link) rl.addWidget(self.detail, 1) self._agent_panel = right self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False)) self._agent_strip.setVisible(False) self._agent_pane = QWidget() apl = QHBoxLayout(self._agent_pane) apl.setContentsMargins(0, 0, 0, 0) apl.setSpacing(0) apl.addWidget(self._agent_strip) apl.addWidget(right, 1) self._split = split split.addWidget(self._agent_pane) split.setChildrenCollapsible(False) split.setSizes([840, 320]) root.addWidget(split, 1) on_language_changed(self._retranslate) # ---- project sandbox lock ----------------------------------------- # ---- helpers ----------------------------------------------------- # ---- Messages (by day, as JSON) -------------------------------------- # ---- scan -------------------------------------------------------- # ---- native interactions ---------------------------------------- # ---- agent Q&A over the graph ----------------------------------- # ---- temporary file-content extraction for Q&A ------------------------ def hideEvent(self, e): # noqa: N802 # Leaving the GraphRAG tab → drop the temporary extracted info. self._clear_extracts() super().hideEvent(e) # -------------------------------------------------------------------------- # Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker) # -------------------------------------------------------------------------- def _pdf_to_markdown(pdf_path, out_dir) -> str | None: """Convert a PDF to Markdown with opendataloader-pdf when available (richer structure than a plain text dump). Best-effort — returns None if the package isn't installed or the call fails, so the caller falls back to doc_extract.""" from pathlib import Path as _P try: import opendataloader_pdf # optional; auto-installed elsewhere if present except Exception: # noqa: BLE001 try: from ...core.deps import ensure_module if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: return None import opendataloader_pdf # noqa: F811 except Exception: # noqa: BLE001 return None out = _P(out_dir) out.mkdir(parents=True, exist_ok=True) for call in ( lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), generate_markdown=True), lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), ): try: call() break except TypeError: continue except Exception: # noqa: BLE001 return None mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) for md in mds: try: return md.read_text(encoding="utf-8", errors="replace") except OSError: continue return None def _extract_file_contents(paths, cache: dict, tmp_dir, max_files: int = 15, max_total: int = 120_000): """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when available, else doc_extract for office/pdf/text). Returns ``(block, cache)`` — ``block`` is the concatenated content for the prompt (bounded), ``cache`` maps path→text for reuse. Never raises.""" from pathlib import Path as _P from ...core import doc_extract cache = dict(cache or {}) parts, total = [], 0 for p in paths[:max_files]: if total >= max_total: break text = cache.get(p) if text is None: try: if _P(p).suffix.lower() == ".pdf": text = _pdf_to_markdown(p, tmp_dir) if not text: text, _n = doc_extract.extract_text(p) else: text, _n = doc_extract.extract_text(p) except Exception: # noqa: BLE001 text = "" cache[p] = text or "" text = cache.get(p) or "" if not text: continue chunk = text[: max(0, max_total - total)] total += len(chunk) parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}') return ("\n\n".join(parts), cache)