diff --git a/presentation/graph/graph_project.py b/presentation/graph/graph_project.py new file mode 100644 index 0000000..4cb6c0a --- /dev/null +++ b/presentation/graph/graph_project.py @@ -0,0 +1,109 @@ +"""Chọn project và đổi chế độ xem cho GraphRAG — R08-T14. + +Đồ thị luôn thuộc về một project. Đổi project là phải quét lại từ đầu, nên +phần này giữ luôn việc dọn kết quả cũ trước khi nạp cái mới. + +Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. +""" +from __future__ import annotations + +from .graph_qa_widget import GraphQaMixin +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 + + +class GraphProjectMixin: + """Chọn project + đổi tab xem. Trộn vào StructureGraphView.""" + + def _retranslate(self) -> None: + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn.setText(tr("structure.browse")) + self._scan_btn.setText(tr("structure.scan")) + self._export_btn.setText(tr("structure.export_png")) + # Both views are named at once now, so neither label depends on state. + self.view_tabs.setTabText(0, tr("structure.graph_btn")) + self.view_tabs.setTabText(1, tr("structure.msgs_btn")) + self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip")) + self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) + self._ag_label.setText(tr("structure.agent_header")) + self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) + self._ask_btn.setText(tr("structure.ask")) + if self._detail_mode == "idle": + self.detail.setPlaceholderText(tr("structure.detail_placeholder")) + self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) + self.project_combo.setToolTip(tr("structure.project_tooltip")) + self._refresh_project_combo() + def _refresh_project_combo(self) -> None: + from ...core.projects import list_projects + + keep = self._active_project_id + self.project_combo.blockSignals(True) + self.project_combo.clear() + self.project_combo.addItem(tr("structure.project_none"), "") + row_to_select = 0 + for i, p in enumerate(list_projects(), start=1): + self.project_combo.addItem(p.name, p.project_id) + if p.project_id == keep: + row_to_select = i + self.project_combo.setCurrentIndex(row_to_select) + self.project_combo.blockSignals(False) + def set_project(self, project_id: str) -> None: + pid = project_id or "" + self._refresh_project_combo() + target = self.project_combo.findData(pid) + if target < 0: + target = 0 + if self.project_combo.currentIndex() == target: + self._on_project_changed(target) + else: + self.project_combo.setCurrentIndex(target) + def _on_project_changed(self, _idx: int) -> None: + from ...core.projects import load_project + + pid = self.project_combo.currentData() or "" + project_changed = pid != self._active_project_id + if project_changed: + self._clear_extracts() # different workspace → drop temp extraction + self._active_project_id = pid + locked = bool(pid) + self.path_edit.setReadOnly(locked) + # Also disable the folder-pick button — otherwise the scan path is only + # "locked" against typing, but the picker could still repoint it outside + # the selected project's sandbox, breaking GraphRAG scope isolation. + self._pick_btn.setEnabled(not locked) + if locked: + project = load_project(pid) + if project is not None: + self.path_edit.setText(str(project.workspace_dir())) + if project_changed: + # Mark it and scan on the next visit rather than now. The rail's + # project picker made switching a one-click thing from any screen, + # and each switch rebuilt this graph — a folder walk plus a force + # layout plus a full setHtml of the D3 page — for a tab that was + # usually not even on screen. auto_scan_and_fit() picks the flag up + # when GraphRAG is actually opened. + self._needs_scan = True + def _pick(self) -> None: + chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) + if chosen: + self.path_edit.setText(chosen) + def _on_view_tab(self, index: int) -> None: + """Tab 0 = graph, tab 1 = messages. Same two views as before, now named + on screen instead of hidden behind one button's changing label.""" + if index == 1: + self._reload_messages() + self._stack.setCurrentWidget(self._msgs_view) + else: + self._stack.setCurrentWidget(self.web if self.web is not None else self.view) diff --git a/presentation/graph/graph_qa_widget.py b/presentation/graph/graph_qa_widget.py new file mode 100644 index 0000000..4bc0a45 --- /dev/null +++ b/presentation/graph/graph_qa_widget.py @@ -0,0 +1,326 @@ +"""Khung hỏi-đáp trên đồ thị GraphRAG — R08-T14. + +Người dùng hỏi một câu về mã nguồn; agent trả lời dựa trên đồ thị vừa quét, +rồi câu trả lời được gắn liên kết tới đúng file và làm nổi các node liên quan. + +``_ask`` dài (119 dòng) vì nó là một lượt chạy hoàn chỉnh: dựng ngữ cảnh từ +đồ thị, gọi provider ở luồng nền, nhận sự kiện phát dần, rồi dựng lại câu trả +lời có liên kết. Cắt nhỏ ra thì phải chuyền qua lại chừng chục biến trạng +thái, đọc còn khó hơn. + +Cùng kiểu mixin như shell và Co4E: các phương thức này đọc/ghi state của +``StructureGraphView`` (đồ thị đang hiển thị, thư mục giải nén tạm, panel +agent). Xem ghi chú ở đầu ``presentation/shell/nav_rail.py``. +""" +from __future__ import annotations + +from .graph_scene import _Node + +# Import muộn trong hàm: structure_graph_view.py trộn chính mixin này vào lớp +# của nó, nên import ở mức module là vòng. + +import re +import sys +from pathlib import Path +from PySide6.QtCore import Qt, QUrl +from ...core.worker import AgentWorker +from ...i18n import tr +from ...ui.osutil import open_folder, open_location +from ...ui.widgets import CollapseStrip + + +class GraphQaMixin: + """Hỏi-đáp trên đồ thị. Trộn vào StructureGraphView.""" + + def _toggle_messages(self) -> None: + """Kept for callers that still ask for a flip (e.g. keyboard paths).""" + showing = self._stack.currentWidget() is self._msgs_view + self.view_tabs.setCurrentIndex(0 if showing else 1) + def _reload_messages(self) -> None: + """Build the tree: day → conversation. Click a conversation to see its + messages as JSON. Scoped to the current project (its history folder).""" + from collections import OrderedDict + + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QTreeWidgetItem + + from ...core.history import list_conversations + self._msgs_view.clear() + pid = self._active_project_id or "" + by_day: "OrderedDict[str, list]" = OrderedDict() + try: + convs = list_conversations(self.ctx.config.history_dir()) + except Exception: # noqa: BLE001 + convs = [] + for conv in convs: + if pid and conv.get("project_id", "default") != pid: + continue + day = (conv.get("created") or "")[:10] or "—" + by_day.setdefault(day, []).append(conv) + if not by_day: + self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) + return + for day in sorted(by_day, reverse=True): + convs_d = by_day[day] + day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) + for conv in convs_d: + it = QTreeWidgetItem([conv.get("title", "(untitled)")]) + it.setData(0, Qt.UserRole, str(conv.get("path", ""))) + day_item.addChild(it) + self._msgs_view.addTopLevelItem(day_item) + day_item.setExpanded(True) + def _show_msg_json(self, item, _col: int = 0) -> None: + import html + import json + + from PySide6.QtCore import Qt + + from ...core.history import load_conversation + path = item.data(0, Qt.UserRole) + if not path: + return + try: + conv = load_conversation(path) + payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), + "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), + "messages": conv.get("messages", [])} + text = json.dumps(payload, ensure_ascii=False, indent=2) + except Exception as exc: # noqa: BLE001 + text = f"(could not read: {exc})" + self.detail.setHtml( + f'
{html.escape(text)}')
+ def _preserve_answer(self) -> None:
+ if self._detail_mode == "answer" and self._answer.strip():
+ self._render_answer()
+ def _set_agent_collapsed(self, collapsed: bool) -> None:
+ strip_w = CollapseStrip.WIDTH + 2
+ self._agent_panel.setVisible(not collapsed)
+ self._agent_strip.setVisible(collapsed)
+ if collapsed:
+ self._agent_pane.setMaximumWidth(strip_w)
+ sizes = self._split.sizes()
+ if len(sizes) == 2:
+ self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
+ else:
+ self._agent_pane.setMaximumWidth(16777215)
+ self._split.setSizes([840, 320])
+ def _matched_sources(self, text: str):
+ if self._graph is None or not text:
+ return []
+ found: dict[str, tuple[str, str, str]] = {}
+ for n in self._graph.nodes:
+ if not n.path:
+ continue
+ label = n.label.rstrip("()")
+ if len(label) < 3:
+ continue
+ if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
+ found[n.path] = (n.kind, n.label, n.detail or n.path)
+ return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
+ def _linkify_files(self, text: str, sources) -> str:
+ """Turn file/entity NAMES mentioned in the answer into clickable links that
+ open the file — so the user can click a name in the answer to view it."""
+ for path, (kind, label, rel) in sources:
+ href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
+ tokens = []
+ base = Path(path).name
+ if base and len(base) >= 3:
+ tokens.append(base)
+ lab = (label or "").rstrip("()").strip()
+ if lab and lab != base and len(lab) >= 3:
+ tokens.append(lab)
+ for tok in tokens:
+ esc = re.escape(tok)
+ # `tok` (code span) → keep the code style but make it a link
+ text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
+ # bare tok, not already inside a link / path / code span
+ text = re.sub(rf"(? None:
+ text = self._answer
+ sources = self._matched_sources(text)
+ if sources:
+ # 1) Make the file/entity names IN THE ANSWER clickable (open on click).
+ text = self._linkify_files(text, sources)
+ # 2) Append a clickable "Related sources" section listing each file.
+ lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
+ for path, (kind, label, rel) in sources:
+ href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
+ # kind badge for context (file/function/section/json_key)
+ kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
+ lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
+ text = "\n".join(lines)
+ self.detail.setMarkdown(text)
+ def _on_detail_link(self, url: QUrl) -> None:
+ if url.isLocalFile():
+ p = url.toLocalFile()
+ # Open the FILE itself for viewing (fall back to its folder for a dir).
+ if Path(p).is_file():
+ open_location(p)
+ else:
+ open_folder(p)
+ def _ask(self) -> None:
+ question = self.ask_edit.text().strip()
+ if not question:
+ return
+ from ...core.skills import parse_skill_command
+ skill_prefix, question, info = parse_skill_command(question)
+ if info is not None:
+ self.detail.setMarkdown(info)
+ self._detail_mode = "answer"
+ self.ask_edit.clear()
+ return
+ if self._graph is None:
+ self.status_message.emit(tr("structure.scan_first"))
+ return
+ context = self._graph_context(self._graph)
+ # Real file CONTENT to answer from (extracted temporarily in the worker):
+ file_paths = self._candidate_file_paths()
+ extract_cache = dict(self._extract_cache)
+ extract_dir = str(self._extract_tmp_dir())
+ self._answer = ""
+ self._detail_mode = "answer"
+ self.detail.setPlainText("…")
+ self.ask_edit.clear()
+
+ active_project_id = self._active_project_id
+
+ # Collect selected node context for auto-filtering
+ selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
+ selected_context = ""
+ if selected_nodes:
+ node_lines = []
+ for nd in selected_nodes:
+ node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
+ if nd.detail:
+ node_lines.append(f" detail: {nd.detail}")
+ # Also gather connected nodes
+ connected_ids = set()
+ for nd in selected_nodes:
+ for edge in self._graph.edges:
+ if edge.source == nd.id:
+ connected_ids.add(edge.target)
+ elif edge.target == nd.id:
+ connected_ids.add(edge.source)
+ connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids]
+ if connected_nodes:
+ node_lines.append("\nConnected nodes:")
+ for cn in connected_nodes:
+ node_lines.append(f"- {cn.label} (kind: {cn.kind})")
+ selected_context = "\n".join(node_lines)
+
+ def job(worker: AgentWorker):
+ provider = self.ctx.build_active_provider()
+ system = ("You answer questions about a code/document knowledge graph. Use the provided "
+ "graph context AND the extracted file contents to retrieve, synthesize and "
+ "explain the answer. Be concise. Answer ONLY from what is provided (graph "
+ "context + extracted contents) — never invent files, functions, or facts that "
+ "aren't in it.\n\n"
+ "EACH answer MUST include source citations so the user can verify where "
+ "information came from. For every factual claim, file reference, or code "
+ "element you mention, add a citation using this format:\n\n"
+ " [source: filename.ext, line/section: XXX]\n\n"
+ "Rules for citations:\n"
+ " 1. Cite the EXACT file path from the graph context (use the path field).\n"
+ " 2. For Python files: cite the function/class name and approximate line "
+ " if available, or the module name.\n"
+ " 3. For document files (.md, .txt): cite the section heading.\n"
+ " 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
+ " 5. Place citations inline after the relevant sentence or fact.\n"
+ " 6. At the end of your answer, add a '---' separator followed by a "
+ " numbered **Sources cited:** section listing each unique source with "
+ " its full path so the user can click to open it.\n\n"
+ "Example citation format in text:\n"
+ " The `process_data()` function handles CSV parsing "
+ "[source: src/utils/parser.py, function: process_data].\n\n"
+ "Example end-of-answer source list:\n"
+ " ---\n"
+ " **Sources cited:**\n"
+ " 1. `src/utils/parser.py` — process_data function\n"
+ " 2. `docs/api.md` — Section: Authentication\n")
+ if skill_prefix:
+ system += "\n\nFollow this skill:\n" + skill_prefix
+ if active_project_id:
+ from ...core.projects import load_project, project_context_text
+ proj_ctx = project_context_text(load_project(active_project_id))
+ if proj_ctx:
+ system += "\n\n" + proj_ctx
+ user_content = f"Graph context:\n{context}"
+ if selected_context:
+ user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
+ # Auto-extract the actual file contents (temporary) so the answer is
+ # synthesized from real content, not just the graph structure.
+ from .structure_graph_view import _extract_file_contents
+ content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir)
+ if content_block:
+ user_content += ("\n\nExtracted file contents (read these to answer about file "
+ "details/data; cite the file path):\n" + content_block)
+ user_content += f"\n\nQuestion: {question}"
+ messages = [
+ {"role": "system", "content": system},
+ {"role": "user", "content": user_content},
+ ]
+ from ...core import agent_roles, audit_log
+ ok = True
+ try:
+ provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
+ cancel=worker.is_cancelled)
+ except Exception:
+ ok = False
+ raise
+ finally:
+ audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
+ agent_role=agent_roles.KNOWLEDGE)
+ return {"extracted": new_cache}
+
+ w = AgentWorker(job)
+ w.event.connect(self._on_ask_event)
+ w.finished_ok.connect(self._on_ask_done)
+ w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
+ self._ask_worker = w
+ w.start()
+ def _on_ask_event(self, ev: dict) -> None:
+ if ev.get("type") == "text":
+ if self._answer == "":
+ self.detail.clear()
+ self._answer += ev.get("delta", "")
+ self.detail.setPlainText(self._answer)
+ def _on_ask_done(self, result: dict) -> None:
+ # Keep the (temporary) extracted content so repeated questions reuse it
+ # without re-extracting — dropped when leaving the tab (_clear_extracts).
+ if isinstance(result, dict):
+ self._extract_cache.update(result.get("extracted", {}) or {})
+ self._render_answer()
+ def _candidate_file_paths(self) -> list:
+ """File paths to read for a question: the SELECTED file nodes if any, else
+ every file node in the graph (capped downstream)."""
+ from pathlib import Path as _P
+ if self._graph is None:
+ return []
+ sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
+ nodes = sel or list(self._graph.nodes)
+ out, seen = [], set()
+ for nd in nodes:
+ p = (getattr(nd, "path", "") or "").strip()
+ if p and p not in seen and _P(p).is_file():
+ seen.add(p)
+ out.append(p)
+ return out
+ def _extract_tmp_dir(self):
+ from pathlib import Path as _P
+ if self._extract_dir is None:
+ import tempfile
+ from ...config import CONFIG_DIR
+ base = CONFIG_DIR / "tmp" / "graphrag_extract"
+ base.mkdir(parents=True, exist_ok=True)
+ self._extract_dir = _P(tempfile.mkdtemp(dir=str(base)))
+ return self._extract_dir
+ def _clear_extracts(self) -> None:
+ """Discard the temporary extracted content (on leaving the tab / switching
+ project). The extraction is a scratch aid, never persisted."""
+ self._extract_cache = {}
+ d, self._extract_dir = self._extract_dir, None
+ if d is not None:
+ import shutil
+ shutil.rmtree(d, ignore_errors=True)
diff --git a/presentation/graph/graph_render.py b/presentation/graph/graph_render.py
new file mode 100644
index 0000000..de53cc6
--- /dev/null
+++ b/presentation/graph/graph_render.py
@@ -0,0 +1,227 @@
+"""Quét mã nguồn, dựng đồ thị, và xuất ảnh — R08-T14.
+
+Hai đường vẽ song song: khung nhìn Qt (``_render``) và bản D3 chạy trong
+QtWebEngine (``_render_d3``). WebEngine nặng nên chỉ dựng ở lần hiện đầu tiên
+(``_ensure_web``), và ``prewarm`` hâm nóng nó lúc rảnh để bấm vào GraphRAG
+không phải ngồi nhìn khung trắng.
+
+Cùng kiểu mixin, xem ghi chú ở đầu ``presentation/shell/nav_rail.py``.
+"""
+from __future__ import annotations
+
+from .graph_scene import _Bridge, _Edge, _Node
+
+from .graph_web import _HAS_WEB, QWebChannel, QWebEngineView
+
+import math
+import re
+from pathlib import Path
+from PySide6.QtCore import QPointF, Qt, QUrl
+from PySide6.QtGui import QColor
+from PySide6.QtWidgets import QFileDialog
+from ...theme import current_palette
+from ...core.worker import AgentWorker
+from ...i18n import tr
+
+
+class GraphRenderMixin:
+ """Quét, vẽ, xuất. Trộn vào StructureGraphView."""
+
+ def schedule_rescan(self, path: str = "") -> None:
+ if self._graph is None:
+ self._needs_scan = True
+ return
+ self._rescan_timer.start()
+ def prewarm(self) -> None:
+ """Pay for the graph view before it is clicked on, not during.
+
+ Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project
+ (~485ms) while an empty browser sat on screen — long enough, and white
+ enough, to read as the app restarting itself. Called from an idle timer
+ after the window is up, so startup itself is unaffected; the memory the
+ lazy construction was saving is spent a few seconds later instead.
+ """
+ if not _HAS_WEB or self.web is not None:
+ return
+ self._ensure_web()
+ if self._graph is None and self.path_edit.text().strip():
+ self._needs_scan = False
+ self._scan() # runs on a worker thread
+ def _ensure_web(self) -> None:
+ if self.web is not None or not _HAS_WEB:
+ return
+ self.web = QWebEngineView()
+ # Blank the page in the app's own background first. A fresh
+ # QWebEngineView paints white, and on a dark theme that white rectangle
+ # WAS the flash — it showed for as long as the first scan took.
+ self.web.setHtml(
+ f"")
+ self._bridge = _Bridge()
+ self._channel = QWebChannel()
+ self._channel.registerObject("py", self._bridge)
+ self.web.page().setWebChannel(self._channel)
+ self._stack.addWidget(self.web)
+ self._stack.setCurrentWidget(self.web)
+ if self._graph is not None:
+ self._render_d3()
+ def auto_scan_and_fit(self) -> None:
+ self._ensure_web()
+ if not self.path_edit.text().strip():
+ return
+ if getattr(self, "_worker", None) is not None and self._worker.isRunning():
+ self._fit()
+ self._preserve_answer()
+ return
+ if self._graph is not None and not self._needs_scan:
+ self._fit()
+ self._preserve_answer()
+ return
+ self._needs_scan = False
+ self._scan()
+ def _scan(self) -> None:
+ path = self.path_edit.text().strip() or str(Path.cwd())
+ mode = "files" # default: scan all files (filter removed)
+ use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
+ cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
+ st = self.ctx.config.structure
+ max_nodes = int(st.get("max_nodes", 500) or 0)
+ max_edges = int(st.get("max_edges", 500) or 0)
+ self._scan_seq += 1
+ seq = self._scan_seq
+ self.status_message.emit(tr("structure.scanning"))
+
+ def job(worker: AgentWorker):
+ from ...core.structure_graph import (
+ build_from_codebase_memory, build_from_directory, force_layout,
+ )
+ if use_cmem:
+ from ...core.codebase_memory import CodebaseMemory
+ mem = CodebaseMemory(cmem_bin)
+ graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
+ if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
+ else:
+ graph = build_from_directory(path, mode, max_nodes, max_edges)
+ pos = force_layout(graph)
+ return {"graph": graph, "pos": pos, "seq": seq}
+
+ w = AgentWorker(job)
+ w.finished_ok.connect(self._render)
+ w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
+ self._worker = w
+ w.start()
+ def _render(self, result: dict) -> None:
+ if result.get("seq") is not None and result["seq"] != self._scan_seq:
+ return
+ graph = result.get("graph")
+ pos = result.get("pos", {})
+ if graph is None:
+ return
+ self._graph = graph
+
+ self.scene.clear()
+ self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear
+ self._node_items = []
+ self._edge_items = []
+ degree = {n.id: 0 for n in graph.nodes}
+ for e in graph.edges:
+ if e.source in degree:
+ degree[e.source] += 1
+ if e.target in degree:
+ degree[e.target] += 1
+ items = {}
+ sx = sy = 0.0
+ for node in graph.nodes:
+ radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
+ item = _Node(node, radius)
+ x, y = pos.get(node.id, (0, 0))
+ item.setPos(x, y)
+ self.scene.addItem(item)
+ items[node.id] = item
+ self._node_items.append(item)
+ sx += x
+ sy += y
+ for edge in graph.edges:
+ a, b = items.get(edge.source), items.get(edge.target)
+ if a and b:
+ e = _Edge(a, b, getattr(edge, "type", ""))
+ self.scene.addItem(e)
+ self._edge_items.append(e)
+ n = max(1, len(self._node_items))
+ self._centroid = QPointF(sx / n, sy / n)
+ self._fit()
+
+ if self.web is not None:
+ self._render_d3()
+
+ note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
+ self.status_message.emit(tr(
+ "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
+ self._preserve_answer()
+ def _render_d3(self) -> None:
+ if self.web is None or self._graph is None:
+ return
+ from ...core.d3_graph import build_html
+ try:
+ self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
+ except Exception as exc:
+ self.status_message.emit(f"D3 view error: {exc}")
+ def _on_selection(self) -> None:
+ for item in self.scene.selectedItems():
+ if isinstance(item, _Node):
+ d = item.data
+ self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}")
+ self._detail_mode = "node"
+ return
+ def _fit(self) -> None:
+ if self.web is not None and self._stack.currentWidget() is self.web:
+ self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
+ return
+ rect = self.scene.itemsBoundingRect()
+ if not rect.isNull():
+ self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
+ def _export(self) -> None:
+ path, _ = QFileDialog.getSaveFileName(
+ self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
+ if not path:
+ return
+ showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
+ if showing_d3:
+ self._export_d3_png(path)
+ else:
+ self._export_widget_grab(path)
+ def _export_d3_png(self, path: str) -> None:
+ def on_result(data_url) -> None:
+ if not isinstance(data_url, str) or "," not in data_url:
+ self._export_widget_grab(path)
+ return
+ import base64
+ try:
+ with open(path, "wb") as f:
+ f.write(base64.b64decode(data_url.split(",", 1)[1]))
+ self.status_message.emit(tr("structure.export_done", path=path))
+ except (OSError, ValueError) as exc:
+ self.status_message.emit(tr("structure.export_failed", err=str(exc)))
+ self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
+ def _export_widget_grab(self, path: str) -> None:
+ ok = self._stack.currentWidget().grab().save(path, "PNG")
+ if ok:
+ self.status_message.emit(tr("structure.export_done", path=path))
+ else:
+ self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
+ @staticmethod
+ def _graph_context(graph) -> str:
+ from collections import defaultdict
+ by_kind = defaultdict(list)
+ for n in graph.nodes:
+ by_kind[n.kind].append(n.label)
+ lines = []
+ for kind in ("file", "class", "function", "method", "module", "section"):
+ items = by_kind.get(kind, [])
+ if items:
+ lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
+ id2label = {n.id: n.label for n in graph.nodes}
+ rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
+ for e in graph.edges[:140]]
+ if rels:
+ lines.append("Relationships (sample):\n" + "\n".join(rels))
+ return "\n".join(lines)[:7000]
diff --git a/presentation/graph/graph_scene.py b/presentation/graph/graph_scene.py
new file mode 100644
index 0000000..138c3aa
--- /dev/null
+++ b/presentation/graph/graph_scene.py
@@ -0,0 +1,138 @@
+"""Các phần tử vẽ của đồ thị: node, cạnh, khung nhìn — R08-T14.
+
+Thuần đồ hoạ Qt, không biết gì về GraphRAG hay agent. Tách riêng vì đây là
+chỗ duy nhất cần mở khi chỉnh cách đồ thị trông ra sao — màu, hình mũi tên,
+cách kéo thả và phóng to.
+"""
+from __future__ import annotations
+
+import re
+from pathlib import Path
+from PySide6.QtCore import QObject, QPointF, Qt, Slot
+from PySide6.QtGui import QBrush, QColor, QFont, QPen
+from PySide6.QtWidgets import QGraphicsEllipseItem, QGraphicsLineItem, QGraphicsSimpleTextItem, QGraphicsView
+from ...core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
+from ...theme import current_palette
+from ...i18n import tr
+from ...ui.osutil import open_folder, open_location
+
+
+class _Bridge(QObject):
+ """Exposed to the D3 page so a Shift+click on a node can open its
+ storage folder/link (local path or URL — see osutil.open_location)."""
+
+ @Slot(str)
+ def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
+ if path:
+ open_location(path)
+
+class _Edge(QGraphicsLineItem):
+ def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
+ super().__init__()
+ self.a, self.b = a, b
+ self.type = type_
+ # Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
+ # so the graph shows what each connection MEANS — falling back to the
+ # source node's tint for any untyped edge.
+ color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
+ if not color.isValid():
+ color = a.brush().color().lighter(130)
+ self._color = color
+ self.setPen(QPen(color, 1.4))
+ self.setZValue(-1)
+ # A small label naming the relationship, shown at the edge midpoint.
+ self._label = None
+ if type_:
+ self._label = QGraphicsSimpleTextItem(type_, self)
+ self._label.setBrush(QBrush(color.lighter(140)))
+ f = QFont()
+ f.setPointSize(7)
+ self._label.setFont(f)
+ self._label.setZValue(0)
+ a.edges.append(self)
+ b.edges.append(self)
+ self.adjust()
+
+ def adjust(self) -> None:
+ pa, pb = self.a.scenePos(), self.b.scenePos()
+ self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
+ if self._label is not None:
+ br = self._label.boundingRect()
+ self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
+ (pa.y() + pb.y()) / 2 - br.height() / 2)
+
+class _Node(QGraphicsEllipseItem):
+ def __init__(self, data, radius: int):
+ super().__init__(-radius, -radius, 2 * radius, 2 * radius)
+ self.data = data
+ self.edges = []
+ tok = current_palette()
+ # NODE_KIND_COLORS is a categorical data encoding (one hue per node
+ # kind), not UI chrome — it stays fixed across themes on purpose so a
+ # given kind is always the same colour. Only the chrome follows tokens.
+ color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
+ self.setBrush(QBrush(color))
+ self.setPen(QPen(color.darker(160), 1.5))
+ self.setFlags(
+ QGraphicsEllipseItem.ItemIsMovable
+ | QGraphicsEllipseItem.ItemIsSelectable
+ | QGraphicsEllipseItem.ItemSendsGeometryChanges
+ )
+ self.setZValue(1)
+ label = QGraphicsSimpleTextItem(data.label, self)
+ label.setBrush(QBrush(QColor(tok.text)))
+ label.setPos(radius + 3, -8)
+
+ def itemChange(self, change, value): # noqa: N802
+ if change == QGraphicsEllipseItem.ItemPositionHasChanged:
+ for edge in self.edges:
+ edge.adjust()
+ return super().itemChange(change, value)
+
+class _GraphView(QGraphicsView):
+ def __init__(self, scene):
+ super().__init__(scene)
+ self.setDragMode(QGraphicsView.NoDrag)
+ self._panning = False
+ self._pan_start = QPointF()
+
+ def wheelEvent(self, e): # noqa: N802
+ self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
+ 1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
+
+ def mousePressEvent(self, e): # noqa: N802
+ if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
+ self._panning = True
+ self._pan_start = e.position()
+ self.setCursor(Qt.ClosedHandCursor)
+ e.accept()
+ return
+ super().mousePressEvent(e)
+
+ def mouseMoveEvent(self, e): # noqa: N802
+ if self._panning:
+ delta = e.position() - self._pan_start
+ self._pan_start = e.position()
+ self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
+ self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
+ e.accept()
+ return
+ super().mouseMoveEvent(e)
+
+ def mouseReleaseEvent(self, e): # noqa: N802
+ if self._panning:
+ self._panning = False
+ self.setCursor(Qt.ArrowCursor)
+ e.accept()
+ return
+ super().mouseReleaseEvent(e)
+
+ def mouseDoubleClickEvent(self, e): # noqa: N802
+ """Double-click or Ctrl+click on a node opens its storage folder."""
+ item = self.itemAt(e.pos())
+ if isinstance(item, _Node) and getattr(item.data, "path", ""):
+ open_folder(item.data.path)
+ e.accept()
+ return
+ super().mouseDoubleClickEvent(e)
+
diff --git a/presentation/graph/graph_web.py b/presentation/graph/graph_web.py
new file mode 100644
index 0000000..0211088
--- /dev/null
+++ b/presentation/graph/graph_web.py
@@ -0,0 +1,38 @@
+"""Có dùng được QtWebEngine hay không — R08-T14.
+
+Cờ khả năng, tách riêng vì cả ``structure_graph_view.py`` lẫn
+``graph_render.py`` đều phải hỏi. Để ở một trong hai thì file kia import
+ngược lại — vòng import.
+
+WebEngine là add-on tuỳ chọn của PySide6, và bản đóng gói one-file của
+PyInstaller không chạy được nó; những trường hợp đó rơi về khung nhìn Qt.
+"""
+from __future__ import annotations
+
+import sys
+from pathlib import Path
+
+def _frozen_onefile() -> bool:
+ """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
+ temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
+ can't run — creating a QWebEngineView hard-crashes the app (reported as
+ "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
+ ``_internal`` folder right next to the exe, where WebEngine works fine, so
+ it keeps the full embedded D3 view."""
+ if not getattr(sys, "frozen", False):
+ return False
+ meipass = getattr(sys, "_MEIPASS", "")
+ if not meipass:
+ return False
+ try:
+ return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
+ except OSError: # can't tell → play safe: use the native fallback
+ return True
+
+
+try: # WebEngine + WebChannel are optional PySide6 add-ons
+ from PySide6.QtWebEngineWidgets import QWebEngineView
+ from PySide6.QtWebChannel import QWebChannel
+ _HAS_WEB = not _frozen_onefile()
+except Exception: # pragma: no cover
+ _HAS_WEB = False
diff --git a/presentation/graph/structure_graph_view.py b/presentation/graph/structure_graph_view.py
new file mode 100644
index 0000000..8d5ed7c
--- /dev/null
+++ b/presentation/graph/structure_graph_view.py
@@ -0,0 +1,325 @@
+"""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)
diff --git a/ui/structure_graph_view.py b/ui/structure_graph_view.py
index b195bf0..5e9a08f 100644
--- a/ui/structure_graph_view.py
+++ b/ui/structure_graph_view.py
@@ -1,1034 +1,11 @@
-"""Structure (RAG) tab — knowledge graph of code / document structure.
+"""Vỏ chuyển tiếp — R08-T14.
-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).
+Phần thân đã chuyển sang ``presentation/graph/``. Giữ đường import cũ vì
+``ui/workspace_tab.py`` và vài checker trong ``tools/`` gọi qua đúng đường
+dẫn này.
"""
from __future__ import annotations
-import math
-import re
-import sys
-from pathlib import Path
+from ..presentation.graph.structure_graph_view import StructureGraphView
-from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot
-from PySide6.QtGui import QBrush, QColor, QFont, QPen
-from PySide6.QtWidgets import (
- QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem,
- QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout,
- QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, QTabBar,
- QTextBrowser, QVBoxLayout, QWidget,
-)
-
-def _frozen_onefile() -> bool:
- """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a
- temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process
- can't run — creating a QWebEngineView hard-crashes the app (reported as
- "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the
- ``_internal`` folder right next to the exe, where WebEngine works fine, so
- it keeps the full embedded D3 view."""
- if not getattr(sys, "frozen", False):
- return False
- meipass = getattr(sys, "_MEIPASS", "")
- if not meipass:
- return False
- try:
- return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent
- except OSError: # can't tell → play safe: use the native fallback
- return True
-
-
-try: # WebEngine + WebChannel are optional PySide6 add-ons
- from PySide6.QtWebEngineWidgets import QWebEngineView
- from PySide6.QtWebChannel import QWebChannel
- _HAS_WEB = not _frozen_onefile()
-except Exception: # pragma: no cover
- _HAS_WEB = False
-
-from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
-from ..theme import current_palette
-from ..core.worker import AgentWorker
-from ..i18n import on_language_changed, tr
-from ..state import AppContext
-from .icons import collapse_right_icon, icon
-from .osutil import open_folder, open_location
-from .widgets import CollapseStrip
-
-try:
- from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available
-except Exception:
- pass
-
-
-class _Bridge(QObject):
- """Exposed to the D3 page so a Shift+click on a node can open its
- storage folder/link (local path or URL — see osutil.open_location)."""
-
- @Slot(str)
- def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name
- if path:
- open_location(path)
-
-
-class _Edge(QGraphicsLineItem):
- def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
- super().__init__()
- self.a, self.b = a, b
- self.type = type_
- # Colour the edge by its RELATIONSHIP type (contains/defines/method/…),
- # so the graph shows what each connection MEANS — falling back to the
- # source node's tint for any untyped edge.
- color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor()
- if not color.isValid():
- color = a.brush().color().lighter(130)
- self._color = color
- self.setPen(QPen(color, 1.4))
- self.setZValue(-1)
- # A small label naming the relationship, shown at the edge midpoint.
- self._label = None
- if type_:
- self._label = QGraphicsSimpleTextItem(type_, self)
- self._label.setBrush(QBrush(color.lighter(140)))
- f = QFont()
- f.setPointSize(7)
- self._label.setFont(f)
- self._label.setZValue(0)
- a.edges.append(self)
- b.edges.append(self)
- self.adjust()
-
- def adjust(self) -> None:
- pa, pb = self.a.scenePos(), self.b.scenePos()
- self.setLine(pa.x(), pa.y(), pb.x(), pb.y())
- if self._label is not None:
- br = self._label.boundingRect()
- self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2,
- (pa.y() + pb.y()) / 2 - br.height() / 2)
-
-
-class _Node(QGraphicsEllipseItem):
- def __init__(self, data, radius: int):
- super().__init__(-radius, -radius, 2 * radius, 2 * radius)
- self.data = data
- self.edges = []
- tok = current_palette()
- # NODE_KIND_COLORS is a categorical data encoding (one hue per node
- # kind), not UI chrome — it stays fixed across themes on purpose so a
- # given kind is always the same colour. Only the chrome follows tokens.
- color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted))
- self.setBrush(QBrush(color))
- self.setPen(QPen(color.darker(160), 1.5))
- self.setFlags(
- QGraphicsEllipseItem.ItemIsMovable
- | QGraphicsEllipseItem.ItemIsSelectable
- | QGraphicsEllipseItem.ItemSendsGeometryChanges
- )
- self.setZValue(1)
- label = QGraphicsSimpleTextItem(data.label, self)
- label.setBrush(QBrush(QColor(tok.text)))
- label.setPos(radius + 3, -8)
-
- def itemChange(self, change, value): # noqa: N802
- if change == QGraphicsEllipseItem.ItemPositionHasChanged:
- for edge in self.edges:
- edge.adjust()
- return super().itemChange(change, value)
-
-
-class _GraphView(QGraphicsView):
- def __init__(self, scene):
- super().__init__(scene)
- self.setDragMode(QGraphicsView.NoDrag)
- self._panning = False
- self._pan_start = QPointF()
-
- def wheelEvent(self, e): # noqa: N802
- self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15,
- 1.15 if e.angleDelta().y() > 0 else 1 / 1.15)
-
- def mousePressEvent(self, e): # noqa: N802
- if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None:
- self._panning = True
- self._pan_start = e.position()
- self.setCursor(Qt.ClosedHandCursor)
- e.accept()
- return
- super().mousePressEvent(e)
-
- def mouseMoveEvent(self, e): # noqa: N802
- if self._panning:
- delta = e.position() - self._pan_start
- self._pan_start = e.position()
- self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x()))
- self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y()))
- e.accept()
- return
- super().mouseMoveEvent(e)
-
- def mouseReleaseEvent(self, e): # noqa: N802
- if self._panning:
- self._panning = False
- self.setCursor(Qt.ArrowCursor)
- e.accept()
- return
- super().mouseReleaseEvent(e)
-
- def mouseDoubleClickEvent(self, e): # noqa: N802
- """Double-click or Ctrl+click on a node opens its storage folder."""
- item = self.itemAt(e.pos())
- if isinstance(item, _Node) and getattr(item.data, "path", ""):
- open_folder(item.data.path)
- e.accept()
- return
- super().mouseDoubleClickEvent(e)
-
-
-class StructureGraphView(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)
-
- def _retranslate(self) -> None:
- self.path_edit.setPlaceholderText(tr("structure.path_placeholder"))
- self._pick_btn.setText(tr("structure.browse"))
- self._scan_btn.setText(tr("structure.scan"))
- self._export_btn.setText(tr("structure.export_png"))
- # Both views are named at once now, so neither label depends on state.
- self.view_tabs.setTabText(0, tr("structure.graph_btn"))
- self.view_tabs.setTabText(1, tr("structure.msgs_btn"))
- self.view_tabs.setTabToolTip(1, tr("structure.msgs_tooltip"))
- self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip"))
- self._ag_label.setText(tr("structure.agent_header"))
- self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder"))
- self._ask_btn.setText(tr("structure.ask"))
- if self._detail_mode == "idle":
- self.detail.setPlaceholderText(tr("structure.detail_placeholder"))
- self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip"))
- self.project_combo.setToolTip(tr("structure.project_tooltip"))
- self._refresh_project_combo()
-
- # ---- project sandbox lock -----------------------------------------
- def _refresh_project_combo(self) -> None:
- from ..core.projects import list_projects
-
- keep = self._active_project_id
- self.project_combo.blockSignals(True)
- self.project_combo.clear()
- self.project_combo.addItem(tr("structure.project_none"), "")
- row_to_select = 0
- for i, p in enumerate(list_projects(), start=1):
- self.project_combo.addItem(p.name, p.project_id)
- if p.project_id == keep:
- row_to_select = i
- self.project_combo.setCurrentIndex(row_to_select)
- self.project_combo.blockSignals(False)
-
- def set_project(self, project_id: str) -> None:
- pid = project_id or ""
- self._refresh_project_combo()
- target = self.project_combo.findData(pid)
- if target < 0:
- target = 0
- if self.project_combo.currentIndex() == target:
- self._on_project_changed(target)
- else:
- self.project_combo.setCurrentIndex(target)
-
- def _on_project_changed(self, _idx: int) -> None:
- from ..core.projects import load_project
-
- pid = self.project_combo.currentData() or ""
- project_changed = pid != self._active_project_id
- if project_changed:
- self._clear_extracts() # different workspace → drop temp extraction
- self._active_project_id = pid
- locked = bool(pid)
- self.path_edit.setReadOnly(locked)
- # Also disable the folder-pick button — otherwise the scan path is only
- # "locked" against typing, but the picker could still repoint it outside
- # the selected project's sandbox, breaking GraphRAG scope isolation.
- self._pick_btn.setEnabled(not locked)
- if locked:
- project = load_project(pid)
- if project is not None:
- self.path_edit.setText(str(project.workspace_dir()))
- if project_changed:
- # Mark it and scan on the next visit rather than now. The rail's
- # project picker made switching a one-click thing from any screen,
- # and each switch rebuilt this graph — a folder walk plus a force
- # layout plus a full setHtml of the D3 page — for a tab that was
- # usually not even on screen. auto_scan_and_fit() picks the flag up
- # when GraphRAG is actually opened.
- self._needs_scan = True
-
- # ---- helpers -----------------------------------------------------
- def _pick(self) -> None:
- chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text())
- if chosen:
- self.path_edit.setText(chosen)
-
- def schedule_rescan(self, path: str = "") -> None:
- if self._graph is None:
- self._needs_scan = True
- return
- self._rescan_timer.start()
-
- # ---- Messages (by day, as JSON) --------------------------------------
- def _on_view_tab(self, index: int) -> None:
- """Tab 0 = graph, tab 1 = messages. Same two views as before, now named
- on screen instead of hidden behind one button's changing label."""
- if index == 1:
- self._reload_messages()
- self._stack.setCurrentWidget(self._msgs_view)
- else:
- self._stack.setCurrentWidget(self.web if self.web is not None else self.view)
-
- def _toggle_messages(self) -> None:
- """Kept for callers that still ask for a flip (e.g. keyboard paths)."""
- showing = self._stack.currentWidget() is self._msgs_view
- self.view_tabs.setCurrentIndex(0 if showing else 1)
-
- def _reload_messages(self) -> None:
- """Build the tree: day → conversation. Click a conversation to see its
- messages as JSON. Scoped to the current project (its history folder)."""
- from collections import OrderedDict
-
- from PySide6.QtCore import Qt
- from PySide6.QtWidgets import QTreeWidgetItem
-
- from ..core.history import list_conversations
- self._msgs_view.clear()
- pid = self._active_project_id or ""
- by_day: "OrderedDict[str, list]" = OrderedDict()
- try:
- convs = list_conversations(self.ctx.config.history_dir())
- except Exception: # noqa: BLE001
- convs = []
- for conv in convs:
- if pid and conv.get("project_id", "default") != pid:
- continue
- day = (conv.get("created") or "")[:10] or "—"
- by_day.setdefault(day, []).append(conv)
- if not by_day:
- self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")]))
- return
- for day in sorted(by_day, reverse=True):
- convs_d = by_day[day]
- day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"])
- for conv in convs_d:
- it = QTreeWidgetItem([conv.get("title", "(untitled)")])
- it.setData(0, Qt.UserRole, str(conv.get("path", "")))
- day_item.addChild(it)
- self._msgs_view.addTopLevelItem(day_item)
- day_item.setExpanded(True)
-
- def _show_msg_json(self, item, _col: int = 0) -> None:
- import html
- import json
-
- from PySide6.QtCore import Qt
-
- from ..core.history import load_conversation
- path = item.data(0, Qt.UserRole)
- if not path:
- return
- try:
- conv = load_conversation(path)
- payload = {"title": conv.get("title", ""), "created": conv.get("created", ""),
- "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""),
- "messages": conv.get("messages", [])}
- text = json.dumps(payload, ensure_ascii=False, indent=2)
- except Exception as exc: # noqa: BLE001
- text = f"(could not read: {exc})"
- self.detail.setHtml(
- f'{html.escape(text)}')
-
- def prewarm(self) -> None:
- """Pay for the graph view before it is clicked on, not during.
-
- Opening GraphRAG built a QWebEngineView (~140ms) and scanned the project
- (~485ms) while an empty browser sat on screen — long enough, and white
- enough, to read as the app restarting itself. Called from an idle timer
- after the window is up, so startup itself is unaffected; the memory the
- lazy construction was saving is spent a few seconds later instead.
- """
- if not _HAS_WEB or self.web is not None:
- return
- self._ensure_web()
- if self._graph is None and self.path_edit.text().strip():
- self._needs_scan = False
- self._scan() # runs on a worker thread
-
- def _ensure_web(self) -> None:
- if self.web is not None or not _HAS_WEB:
- return
- self.web = QWebEngineView()
- # Blank the page in the app's own background first. A fresh
- # QWebEngineView paints white, and on a dark theme that white rectangle
- # WAS the flash — it showed for as long as the first scan took.
- self.web.setHtml(
- f"")
- self._bridge = _Bridge()
- self._channel = QWebChannel()
- self._channel.registerObject("py", self._bridge)
- self.web.page().setWebChannel(self._channel)
- self._stack.addWidget(self.web)
- self._stack.setCurrentWidget(self.web)
- if self._graph is not None:
- self._render_d3()
-
- def auto_scan_and_fit(self) -> None:
- self._ensure_web()
- if not self.path_edit.text().strip():
- return
- if getattr(self, "_worker", None) is not None and self._worker.isRunning():
- self._fit()
- self._preserve_answer()
- return
- if self._graph is not None and not self._needs_scan:
- self._fit()
- self._preserve_answer()
- return
- self._needs_scan = False
- self._scan()
-
- # ---- scan --------------------------------------------------------
- def _scan(self) -> None:
- path = self.path_edit.text().strip() or str(Path.cwd())
- mode = "files" # default: scan all files (filter removed)
- use_cmem = bool(self.ctx.config.codebase_memory.get("enabled"))
- cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "")
- st = self.ctx.config.structure
- max_nodes = int(st.get("max_nodes", 500) or 0)
- max_edges = int(st.get("max_edges", 500) or 0)
- self._scan_seq += 1
- seq = self._scan_seq
- self.status_message.emit(tr("structure.scanning"))
-
- def job(worker: AgentWorker):
- from ..core.structure_graph import (
- build_from_codebase_memory, build_from_directory, force_layout,
- )
- if use_cmem:
- from ..core.codebase_memory import CodebaseMemory
- mem = CodebaseMemory(cmem_bin)
- graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges)
- if mem.available else build_from_directory(path, mode, max_nodes, max_edges))
- else:
- graph = build_from_directory(path, mode, max_nodes, max_edges)
- pos = force_layout(graph)
- return {"graph": graph, "pos": pos, "seq": seq}
-
- w = AgentWorker(job)
- w.finished_ok.connect(self._render)
- w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e)))
- self._worker = w
- w.start()
-
- def _render(self, result: dict) -> None:
- if result.get("seq") is not None and result["seq"] != self._scan_seq:
- return
- graph = result.get("graph")
- pos = result.get("pos", {})
- if graph is None:
- return
- self._graph = graph
-
- self.scene.clear()
- self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear
- self._node_items = []
- self._edge_items = []
- degree = {n.id: 0 for n in graph.nodes}
- for e in graph.edges:
- if e.source in degree:
- degree[e.source] += 1
- if e.target in degree:
- degree[e.target] += 1
- items = {}
- sx = sy = 0.0
- for node in graph.nodes:
- radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0))))
- item = _Node(node, radius)
- x, y = pos.get(node.id, (0, 0))
- item.setPos(x, y)
- self.scene.addItem(item)
- items[node.id] = item
- self._node_items.append(item)
- sx += x
- sy += y
- for edge in graph.edges:
- a, b = items.get(edge.source), items.get(edge.target)
- if a and b:
- e = _Edge(a, b, getattr(edge, "type", ""))
- self.scene.addItem(e)
- self._edge_items.append(e)
- n = max(1, len(self._node_items))
- self._centroid = QPointF(sx / n, sy / n)
- self._fit()
-
- if self.web is not None:
- self._render_d3()
-
- note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else ""
- self.status_message.emit(tr(
- "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note))
- self._preserve_answer()
-
- def _render_d3(self) -> None:
- if self.web is None or self._graph is None:
- return
- from ..core.d3_graph import build_html
- try:
- self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/"))
- except Exception as exc:
- self.status_message.emit(f"D3 view error: {exc}")
-
- # ---- native interactions ----------------------------------------
- def _on_selection(self) -> None:
- for item in self.scene.selectedItems():
- if isinstance(item, _Node):
- d = item.data
- self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}")
- self._detail_mode = "node"
- return
-
- def _preserve_answer(self) -> None:
- if self._detail_mode == "answer" and self._answer.strip():
- self._render_answer()
-
- def _set_agent_collapsed(self, collapsed: bool) -> None:
- strip_w = CollapseStrip.WIDTH + 2
- self._agent_panel.setVisible(not collapsed)
- self._agent_strip.setVisible(collapsed)
- if collapsed:
- self._agent_pane.setMaximumWidth(strip_w)
- sizes = self._split.sizes()
- if len(sizes) == 2:
- self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
- else:
- self._agent_pane.setMaximumWidth(16777215)
- self._split.setSizes([840, 320])
-
- def _fit(self) -> None:
- if self.web is not None and self._stack.currentWidget() is self.web:
- self.web.page().runJavaScript("window.fitGraph && window.fitGraph();")
- return
- rect = self.scene.itemsBoundingRect()
- if not rect.isNull():
- self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio)
-
- def _export(self) -> None:
- path, _ = QFileDialog.getSaveFileName(
- self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
- if not path:
- return
- showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web)
- if showing_d3:
- self._export_d3_png(path)
- else:
- self._export_widget_grab(path)
-
- def _export_d3_png(self, path: str) -> None:
- def on_result(data_url) -> None:
- if not isinstance(data_url, str) or "," not in data_url:
- self._export_widget_grab(path)
- return
- import base64
- try:
- with open(path, "wb") as f:
- f.write(base64.b64decode(data_url.split(",", 1)[1]))
- self.status_message.emit(tr("structure.export_done", path=path))
- except (OSError, ValueError) as exc:
- self.status_message.emit(tr("structure.export_failed", err=str(exc)))
- self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
-
- def _export_widget_grab(self, path: str) -> None:
- ok = self._stack.currentWidget().grab().save(path, "PNG")
- if ok:
- self.status_message.emit(tr("structure.export_done", path=path))
- else:
- self.status_message.emit(tr("structure.export_failed", err="grab() returned no image"))
-
- # ---- agent Q&A over the graph -----------------------------------
- @staticmethod
- def _graph_context(graph) -> str:
- from collections import defaultdict
- by_kind = defaultdict(list)
- for n in graph.nodes:
- by_kind[n.kind].append(n.label)
- lines = []
- for kind in ("file", "class", "function", "method", "module", "section"):
- items = by_kind.get(kind, [])
- if items:
- lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60]))
- id2label = {n.id: n.label for n in graph.nodes}
- rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}"
- for e in graph.edges[:140]]
- if rels:
- lines.append("Relationships (sample):\n" + "\n".join(rels))
- return "\n".join(lines)[:7000]
-
- def _matched_sources(self, text: str):
- if self._graph is None or not text:
- return []
- found: dict[str, tuple[str, str, str]] = {}
- for n in self._graph.nodes:
- if not n.path:
- continue
- label = n.label.rstrip("()")
- if len(label) < 3:
- continue
- if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text):
- found[n.path] = (n.kind, n.label, n.detail or n.path)
- return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12]
-
- def _linkify_files(self, text: str, sources) -> str:
- """Turn file/entity NAMES mentioned in the answer into clickable links that
- open the file — so the user can click a name in the answer to view it."""
- for path, (kind, label, rel) in sources:
- href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
- tokens = []
- base = Path(path).name
- if base and len(base) >= 3:
- tokens.append(base)
- lab = (label or "").rstrip("()").strip()
- if lab and lab != base and len(lab) >= 3:
- tokens.append(lab)
- for tok in tokens:
- esc = re.escape(tok)
- # `tok` (code span) → keep the code style but make it a link
- text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
- # bare tok, not already inside a link / path / code span
- text = re.sub(rf"(? None:
- text = self._answer
- sources = self._matched_sources(text)
- if sources:
- # 1) Make the file/entity names IN THE ANSWER clickable (open on click).
- text = self._linkify_files(text, sources)
- # 2) Append a clickable "Related sources" section listing each file.
- lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
- for path, (kind, label, rel) in sources:
- href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
- # kind badge for context (file/function/section/json_key)
- kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else ""
- lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`")
- text = "\n".join(lines)
- self.detail.setMarkdown(text)
-
- def _on_detail_link(self, url: QUrl) -> None:
- if url.isLocalFile():
- p = url.toLocalFile()
- # Open the FILE itself for viewing (fall back to its folder for a dir).
- if Path(p).is_file():
- open_location(p)
- else:
- open_folder(p)
-
- def _ask(self) -> None:
- question = self.ask_edit.text().strip()
- if not question:
- return
- from ..core.skills import parse_skill_command
- skill_prefix, question, info = parse_skill_command(question)
- if info is not None:
- self.detail.setMarkdown(info)
- self._detail_mode = "answer"
- self.ask_edit.clear()
- return
- if self._graph is None:
- self.status_message.emit(tr("structure.scan_first"))
- return
- context = self._graph_context(self._graph)
- # Real file CONTENT to answer from (extracted temporarily in the worker):
- file_paths = self._candidate_file_paths()
- extract_cache = dict(self._extract_cache)
- extract_dir = str(self._extract_tmp_dir())
- self._answer = ""
- self._detail_mode = "answer"
- self.detail.setPlainText("…")
- self.ask_edit.clear()
-
- active_project_id = self._active_project_id
-
- # Collect selected node context for auto-filtering
- selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
- selected_context = ""
- if selected_nodes:
- node_lines = []
- for nd in selected_nodes:
- node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})")
- if nd.detail:
- node_lines.append(f" detail: {nd.detail}")
- # Also gather connected nodes
- connected_ids = set()
- for nd in selected_nodes:
- for edge in self._graph.edges:
- if edge.source == nd.id:
- connected_ids.add(edge.target)
- elif edge.target == nd.id:
- connected_ids.add(edge.source)
- connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids]
- if connected_nodes:
- node_lines.append("\nConnected nodes:")
- for cn in connected_nodes:
- node_lines.append(f"- {cn.label} (kind: {cn.kind})")
- selected_context = "\n".join(node_lines)
-
- def job(worker: AgentWorker):
- provider = self.ctx.build_active_provider()
- system = ("You answer questions about a code/document knowledge graph. Use the provided "
- "graph context AND the extracted file contents to retrieve, synthesize and "
- "explain the answer. Be concise. Answer ONLY from what is provided (graph "
- "context + extracted contents) — never invent files, functions, or facts that "
- "aren't in it.\n\n"
- "EACH answer MUST include source citations so the user can verify where "
- "information came from. For every factual claim, file reference, or code "
- "element you mention, add a citation using this format:\n\n"
- " [source: filename.ext, line/section: XXX]\n\n"
- "Rules for citations:\n"
- " 1. Cite the EXACT file path from the graph context (use the path field).\n"
- " 2. For Python files: cite the function/class name and approximate line "
- " if available, or the module name.\n"
- " 3. For document files (.md, .txt): cite the section heading.\n"
- " 4. For JSON files: cite the key path (e.g. settings > database > host).\n"
- " 5. Place citations inline after the relevant sentence or fact.\n"
- " 6. At the end of your answer, add a '---' separator followed by a "
- " numbered **Sources cited:** section listing each unique source with "
- " its full path so the user can click to open it.\n\n"
- "Example citation format in text:\n"
- " The `process_data()` function handles CSV parsing "
- "[source: src/utils/parser.py, function: process_data].\n\n"
- "Example end-of-answer source list:\n"
- " ---\n"
- " **Sources cited:**\n"
- " 1. `src/utils/parser.py` — process_data function\n"
- " 2. `docs/api.md` — Section: Authentication\n")
- if skill_prefix:
- system += "\n\nFollow this skill:\n" + skill_prefix
- if active_project_id:
- from ..core.projects import load_project, project_context_text
- proj_ctx = project_context_text(load_project(active_project_id))
- if proj_ctx:
- system += "\n\n" + proj_ctx
- user_content = f"Graph context:\n{context}"
- if selected_context:
- user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}"
- # Auto-extract the actual file contents (temporary) so the answer is
- # synthesized from real content, not just the graph structure.
- content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir)
- if content_block:
- user_content += ("\n\nExtracted file contents (read these to answer about file "
- "details/data; cite the file path):\n" + content_block)
- user_content += f"\n\nQuestion: {question}"
- messages = [
- {"role": "system", "content": system},
- {"role": "user", "content": user_content},
- ]
- from ..core import agent_roles, audit_log
- ok = True
- try:
- provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}),
- cancel=worker.is_cancelled)
- except Exception:
- ok = False
- raise
- finally:
- audit_log.record("tool_call", "graphrag_ask", ok, question[:500],
- agent_role=agent_roles.KNOWLEDGE)
- return {"extracted": new_cache}
-
- w = AgentWorker(job)
- w.event.connect(self._on_ask_event)
- w.finished_ok.connect(self._on_ask_done)
- w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}"))
- self._ask_worker = w
- w.start()
-
- def _on_ask_event(self, ev: dict) -> None:
- if ev.get("type") == "text":
- if self._answer == "":
- self.detail.clear()
- self._answer += ev.get("delta", "")
- self.detail.setPlainText(self._answer)
-
- def _on_ask_done(self, result: dict) -> None:
- # Keep the (temporary) extracted content so repeated questions reuse it
- # without re-extracting — dropped when leaving the tab (_clear_extracts).
- if isinstance(result, dict):
- self._extract_cache.update(result.get("extracted", {}) or {})
- self._render_answer()
-
- # ---- temporary file-content extraction for Q&A ------------------------
- def _candidate_file_paths(self) -> list:
- """File paths to read for a question: the SELECTED file nodes if any, else
- every file node in the graph (capped downstream)."""
- from pathlib import Path as _P
- if self._graph is None:
- return []
- sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
- nodes = sel or list(self._graph.nodes)
- out, seen = [], set()
- for nd in nodes:
- p = (getattr(nd, "path", "") or "").strip()
- if p and p not in seen and _P(p).is_file():
- seen.add(p)
- out.append(p)
- return out
-
- def _extract_tmp_dir(self):
- from pathlib import Path as _P
- if self._extract_dir is None:
- import tempfile
- from ..config import CONFIG_DIR
- base = CONFIG_DIR / "tmp" / "graphrag_extract"
- base.mkdir(parents=True, exist_ok=True)
- self._extract_dir = _P(tempfile.mkdtemp(dir=str(base)))
- return self._extract_dir
-
- def _clear_extracts(self) -> None:
- """Discard the temporary extracted content (on leaving the tab / switching
- project). The extraction is a scratch aid, never persisted."""
- self._extract_cache = {}
- d, self._extract_dir = self._extract_dir, None
- if d is not None:
- import shutil
- shutil.rmtree(d, ignore_errors=True)
-
- 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)
+__all__ = ["StructureGraphView"]