presentation/graph/
structure_graph_view.py 325 lớp chính + dựng giao diện
graph_qa_widget.py 322 hỏi-đáp trên đồ thị (_ask 119 dòng)
graph_render.py 226 quét, vẽ Qt + D3, xuất ảnh
graph_scene.py 138 node, cạnh, khung nhìn — thuần đồ hoạ
graph_project.py 109 chọn project, đổi tab xem
graph_web.py 38 cờ có dùng được QtWebEngine không
ui/structure_graph_view.py 11 vỏ chuyển tiếp, giữ đường import cũ
BA LẦN CẮT HỎNG, ĐỀU LÀ TÊN CẤP MODULE BỊ BỎ LẠI
------------------------------------------------
_HAS_WEB, QWebEngineView, QWebChannel, _Bridge, _Edge, _Node — tất cả định
nghĩa ở file gốc, dùng ở file mới, nên NameError ngay lúc chạy. Bộ test đơn
vị KHÔNG bắt được cái nào: 756 bài vẫn xanh suốt ba lần. Chỉ
check_graphrag_rescan bắt, vì nó gọi prewarm() thật rồi chờ đồ thị dựng xong.
Sau lần thứ ba tôi bỏ cách đuổi từng lỗi và viết bộ dò tên chưa định nghĩa có
tính đến phạm vi hàm (tham số, biến cục bộ, except-as, comprehension). Nó
tìm ra nốt _fmt_plan và _qcolor còn thiếu ở hai file Co4E đã tách hôm trước —
hai quả mìn chưa nổ.
_HAS_WEB tách hẳn ra graph_web.py: cả structure_graph_view.py lẫn
graph_render.py đều phải hỏi, để ở một trong hai là vòng import.
756 test xanh. 24/24 checker qua.
Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
326 lines
12 KiB
Python
326 lines
12 KiB
Python
"""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)
|