## Summary epic r04 - begin refactor ## Change Type - [x] Cowork feature - [ ] Bug fix - [ ] Core AI contribution - [ ] Test / hardening - [ ] Performance - [ ] Documentation ## Related Work Cowork Task: Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets Core AI Issue: Core Task: Related PR: ## Scope What is intentionally included? What is intentionally NOT included? ## Validation - [ ] Unit tests - [ ] Integration tests - [ ] Manual verification - [ ] Regression check Commands / evidence: ## Security Impact Permission / credential / network / customer data impact: ## Compatibility - [ ] No breaking change - [ ] Breaking change documented ## Reviewer Notes Anything Cowork reviewers should pay attention to. --------- Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com> Co-authored-by: Huong Le Thi Thien <huongltt35@fpt.com> Co-authored-by: Nam Pham Dinh Thanh <nampdt@fpt.com> Co-authored-by: Vu Dam Tuan <vudt15@fpt.com> Co-authored-by: Hiep Ha Van <hiephv3@fpt.com> Co-authored-by: Lam Hoang Van <lamhv7@fpt.com> Reviewed-on: #7 Co-authored-by: Duy Le Huu <duylh19@fpt.com>
This commit was merged in pull request #7.
This commit is contained in:
@@ -0,0 +1,3 @@
|
||||
"""GraphRAG (Structure) screen, split into single-responsibility widgets
|
||||
(R08-T14): ``graph_scene_items``, ``graph_renderer``, ``graph_qa_widget``,
|
||||
assembled by the ``structure_graph_view`` shell."""
|
||||
@@ -0,0 +1,81 @@
|
||||
"""Xuất khung đồ thị đang xem ra file PNG — tách khỏi ``graph_renderer.py``.
|
||||
|
||||
GraphRAG có hai khung xem: cảnh Qt 2D và trang D3 chạy trong WebEngine. Hai
|
||||
khung ấy chụp ảnh theo hai cách hoàn toàn khác nhau (``QWidget.grab()`` so
|
||||
với một lượt gọi JavaScript trả về data URL), nên chỗ này gom cả hai lại sau
|
||||
một hàm duy nhất và tự chọn đường đi.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import base64
|
||||
from typing import Callable, Optional
|
||||
|
||||
from PySide6.QtWidgets import QFileDialog, QWidget
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
#: Bên gọi truyền vào để hiện kết quả trên thanh trạng thái.
|
||||
StatusFn = Callable[[str], None]
|
||||
|
||||
|
||||
def ask_export_path(parent: QWidget) -> str:
|
||||
"""Hỏi người dùng nơi lưu ảnh; trả về '' nếu họ bấm Huỷ."""
|
||||
path, _ = QFileDialog.getSaveFileName(
|
||||
parent, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)")
|
||||
return path or ""
|
||||
|
||||
|
||||
def export_widget_grab(widget: QWidget, path: str, status: StatusFn) -> None:
|
||||
"""Chụp thẳng widget đang hiện ra PNG.
|
||||
|
||||
Đây cũng là đường lui khi xuất từ D3 thất bại: chụp widget luôn cho ra
|
||||
một tấm ảnh, dù không sắc nét bằng bản vẽ vector của D3.
|
||||
"""
|
||||
if widget.grab().save(path, "PNG"):
|
||||
status(tr("structure.export_done", path=path))
|
||||
else:
|
||||
status(tr("structure.export_failed", err="grab() returned no image"))
|
||||
|
||||
|
||||
def export_d3_png(web, path: str, fallback: QWidget, status: StatusFn) -> None:
|
||||
"""Xuất PNG từ trang D3 bằng cách nhờ chính trang đó vẽ ra data URL.
|
||||
|
||||
``runJavaScript`` chạy bất đồng bộ nên kết quả về trong hàm gọi lại. Mọi
|
||||
đường hỏng — trang chưa nạp xong, ``window.exportPng`` không tồn tại,
|
||||
chuỗi trả về không phải data URL — đều quay sang chụp widget, để người
|
||||
dùng bấm Xuất vẫn luôn nhận được một file thay vì im lặng không có gì.
|
||||
"""
|
||||
def on_result(data_url) -> None:
|
||||
"""Nhận data URL từ trang D3 và ghi ra file; hỏng ở bất cứ đâu thì quay sang
|
||||
chụp widget.
|
||||
"""
|
||||
if not isinstance(data_url, str) or "," not in data_url:
|
||||
export_widget_grab(fallback, path, status)
|
||||
return
|
||||
try:
|
||||
with open(path, "wb") as f:
|
||||
f.write(base64.b64decode(data_url.split(",", 1)[1]))
|
||||
status(tr("structure.export_done", path=path))
|
||||
except (OSError, ValueError) as exc:
|
||||
status(tr("structure.export_failed", err=str(exc)))
|
||||
|
||||
web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result)
|
||||
|
||||
|
||||
def export_png(parent: QWidget, showing: QWidget, web: Optional[object],
|
||||
is_web_visible: bool, status: StatusFn) -> None:
|
||||
"""Hỏi đường dẫn rồi xuất khung ĐANG hiện — D3 hay cảnh Qt tuỳ tab đang mở.
|
||||
|
||||
Chỉ dùng đường D3 khi trang D3 thật sự đang hiển thị; đang xem cảnh Qt mà
|
||||
lại chụp D3 thì ảnh ra không khớp với thứ người dùng nhìn thấy.
|
||||
"""
|
||||
path = ask_export_path(parent)
|
||||
if not path:
|
||||
return
|
||||
if web is not None and is_web_visible:
|
||||
export_d3_png(web, path, showing, status)
|
||||
else:
|
||||
export_widget_grab(showing, path, status)
|
||||
|
||||
|
||||
__all__ = ["export_png", "export_d3_png", "export_widget_grab", "ask_export_path"]
|
||||
@@ -0,0 +1,110 @@
|
||||
"""GraphMessagesView — the "Messages by day" tab of GraphRAG (R08-T14, split
|
||||
out of ``graph_renderer.py`` to keep that file under the 400-line cap;
|
||||
originally ``ui/structure_graph_view.py``, lines 427-497 of the original
|
||||
1035-line file: ``_on_view_tab``, ``_toggle_messages``, ``_reload_messages``,
|
||||
``_show_msg_json``).
|
||||
|
||||
A plain (non-Qt-widget) helper composed BY ``GraphRenderer`` — same
|
||||
composition-to-respect-the-line-cap pattern as
|
||||
``office_document_renderer.py``. Owns the ``QTreeWidget`` itself (built
|
||||
here, added to the owner's stack at construction) since nothing else needs
|
||||
it, but reaches into ``owner._stack``/``owner.web``/``owner.view``/
|
||||
``owner.active_project_id`` to switch the shared stack and scope by project.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from collections import OrderedDict
|
||||
|
||||
from PySide6.QtCore import Qt
|
||||
from PySide6.QtWidgets import QTreeWidget, QTreeWidgetItem
|
||||
|
||||
from cowork_local.i18n import tr
|
||||
|
||||
|
||||
class GraphMessagesView:
|
||||
"""Tab "Tin nhắn" của GraphRAG: cây hội thoại gom theo ngày, bấm vào xem JSON.
|
||||
|
||||
Một helper thuần (không phải widget) do ``GraphRenderer`` sở hữu; nó tự dựng
|
||||
cây và đưa vào stack dùng chung của chủ sở hữu.
|
||||
"""
|
||||
def __init__(self, owner) -> None:
|
||||
"""Dựng cây tin nhắn và đưa vào stack dùng chung của chủ sở hữu — helper thuần,
|
||||
không phải widget độc lập.
|
||||
"""
|
||||
self._owner = owner
|
||||
self.widget = QTreeWidget()
|
||||
self.widget.setHeaderHidden(True)
|
||||
self.widget.itemClicked.connect(self._show_msg_json)
|
||||
owner._stack.addWidget(self.widget)
|
||||
|
||||
def on_view_tab(self, index: int) -> None:
|
||||
"""Tab 0 = graph, tab 1 = messages."""
|
||||
o = self._owner
|
||||
if index == 1:
|
||||
self.reload()
|
||||
o._stack.setCurrentWidget(self.widget)
|
||||
else:
|
||||
o._stack.setCurrentWidget(o.web if o.web is not None else o.view)
|
||||
|
||||
def toggle(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
o = self._owner
|
||||
showing = o._stack.currentWidget() is self.widget
|
||||
o.view_tabs.setCurrentIndex(0 if showing else 1)
|
||||
|
||||
def reload(self) -> None:
|
||||
"""Build the tree: day -> conversation. Click a conversation to see
|
||||
its messages as JSON. Scoped to the current project's history."""
|
||||
from cowork_local.core.history import list_conversations
|
||||
|
||||
o = self._owner
|
||||
self.widget.clear()
|
||||
pid = o.active_project_id or ""
|
||||
by_day: "OrderedDict[str, list]" = OrderedDict()
|
||||
try:
|
||||
convs = list_conversations(o.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.widget.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.widget.addTopLevelItem(day_item)
|
||||
day_item.setExpanded(True)
|
||||
|
||||
def _show_msg_json(self, item, _col: int = 0) -> None:
|
||||
"""Bấm một hội thoại trong cây: hiện toàn bộ nội dung của nó dưới dạng JSON đã
|
||||
thoát HTML ở khung chi tiết.
|
||||
"""
|
||||
import html
|
||||
import json
|
||||
|
||||
from cowork_local.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._owner.raw_json_ready.emit(
|
||||
f'<pre style="white-space:pre-wrap; font-family:Consolas,monospace; '
|
||||
f'font-size:12px;">{html.escape(text)}</pre>')
|
||||
|
||||
|
||||
__all__ = ["GraphMessagesView"]
|
||||
@@ -0,0 +1,397 @@
|
||||
"""GraphQaWidget — the right-side "ask questions about this graph" panel of
|
||||
GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 288-334/342-360
|
||||
(partial)/647-663/704-955 of the original 1035-line file).
|
||||
|
||||
Reads the current graph and scene selection from a
|
||||
``graph_renderer.py::GraphRenderer`` instance passed at construction
|
||||
(``renderer.graph``, ``renderer.selected_node_data()``,
|
||||
``renderer.active_project_id``) and reacts to its
|
||||
``node_selected``/``graph_rendered``/``raw_json_ready`` signals — this class
|
||||
has no rendering state of its own, matching how
|
||||
``presentation/folder/ai_file_editor_dialog.py`` reads
|
||||
``DocumentPreviewManager`` rather than duplicating file state.
|
||||
|
||||
File-content extraction for grounding the answer goes through
|
||||
``application/workspaces/graph_index_service.py`` (R08-T14 also moved that
|
||||
out of this file, as pure Python — see its own docstring).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import re
|
||||
from pathlib import Path
|
||||
from typing import List, Optional, Tuple
|
||||
|
||||
from PySide6.QtCore import QUrl, Signal
|
||||
from PySide6.QtWidgets import QHBoxLayout, QLabel, QLineEdit, QPushButton, QTextBrowser, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.application.workspaces.graph_index_service import extract_file_contents
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import tr
|
||||
from cowork_local.ui.icons import collapse_right_icon, icon
|
||||
from cowork_local.ui.osutil import open_folder, open_location
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
|
||||
class GraphQaWidget(QWidget):
|
||||
"""The collapsible pane itself (strip + header + ask row + detail
|
||||
browser) — the shell adds ONE widget to its splitter."""
|
||||
|
||||
status_message = Signal(str)
|
||||
collapse_changed = Signal(bool) # so the shell can resize its own splitter
|
||||
|
||||
def __init__(self, ctx, renderer, parent=None):
|
||||
"""Panel Hỏi đáp bên phải màn GraphRAG.
|
||||
|
||||
Có cache trích xuất vì cùng một node hay bị hỏi lại nhiều lần trong một
|
||||
phiên, mà mỗi lần trích lại phải đọc tệp.
|
||||
"""
|
||||
super().__init__(parent)
|
||||
self.ctx = ctx
|
||||
self._renderer = renderer
|
||||
self._ask_worker: Optional[AgentWorker] = None
|
||||
self._answer = ""
|
||||
self._detail_mode = "idle" # "answer" | "node" | "idle"
|
||||
self._extract_cache: dict = {}
|
||||
self._extract_dir = None
|
||||
|
||||
outer = QHBoxLayout(self)
|
||||
outer.setContentsMargins(0, 0, 0, 0)
|
||||
outer.setSpacing(0)
|
||||
self._strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left")
|
||||
self._strip.clicked.connect(lambda: self._set_collapsed(False))
|
||||
self._strip.setVisible(False)
|
||||
outer.addWidget(self._strip)
|
||||
|
||||
self._panel = QWidget()
|
||||
rl = QVBoxLayout(self._panel)
|
||||
rl.setContentsMargins(0, 0, 0, 0)
|
||||
ag_hdr = QHBoxLayout()
|
||||
self._collapse_btn = QPushButton()
|
||||
self._collapse_btn.setIcon(collapse_right_icon())
|
||||
self._collapse_btn.setFixedWidth(28)
|
||||
self._collapse_btn.clicked.connect(lambda: self._set_collapsed(True))
|
||||
self._label = QLabel()
|
||||
ag_hdr.addWidget(self._collapse_btn)
|
||||
ag_hdr.addWidget(self._label, 1)
|
||||
rl.addLayout(ag_hdr)
|
||||
|
||||
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)
|
||||
|
||||
self.detail = QTextBrowser()
|
||||
self.detail.setReadOnly(True)
|
||||
self.detail.setOpenLinks(False)
|
||||
self.detail.anchorClicked.connect(self._on_detail_link)
|
||||
rl.addWidget(self.detail, 1)
|
||||
outer.addWidget(self._panel, 1)
|
||||
|
||||
renderer.node_selected.connect(self._on_node_selected)
|
||||
renderer.graph_rendered.connect(self._preserve_answer)
|
||||
renderer.raw_json_ready.connect(self._show_raw_json)
|
||||
renderer.project_changed.connect(self.clear_extracts)
|
||||
|
||||
self.retranslate()
|
||||
|
||||
def retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho tiêu đề, ô hỏi và tooltip."""
|
||||
self._collapse_btn.setToolTip(tr("structure.collapse_agent_tooltip"))
|
||||
self._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._strip.setToolTip(tr("structure.expand_agent_tooltip"))
|
||||
|
||||
# ---- collapse ------------------------------------------------------------- #
|
||||
def _set_collapsed(self, collapsed: bool) -> None:
|
||||
"""Gập/mở panel hỏi-đáp và báo ra ngoài để vỏ chỉnh lại splitter."""
|
||||
self._panel.setVisible(not collapsed)
|
||||
self._strip.setVisible(collapsed)
|
||||
self.collapse_changed.emit(collapsed)
|
||||
|
||||
# ---- reacting to the renderer ----------------------------------------------- #
|
||||
def _on_node_selected(self, data) -> None:
|
||||
"""Chọn một node trên đồ thị: hiện chi tiết của node đó ở khung dưới."""
|
||||
self.detail.setPlainText(f"[{data.kind.upper()}] {data.label}\n\n{data.detail}")
|
||||
self._detail_mode = "node"
|
||||
|
||||
def _show_raw_json(self, html_text: str) -> None:
|
||||
"""Hiện JSON thô của một hội thoại được chọn ở tab Tin nhắn."""
|
||||
self.detail.setHtml(html_text)
|
||||
|
||||
def _preserve_answer(self) -> None:
|
||||
"""Vẽ lại câu trả lời AI sau khi khung chi tiết bị dùng cho việc khác — nếu"""
|
||||
if self._detail_mode == "answer" and self._answer.strip():
|
||||
self._render_answer()
|
||||
|
||||
# ---- Q&A -------------------------------------------------------------------- #
|
||||
@staticmethod
|
||||
def _graph_context(graph) -> str:
|
||||
"""Tóm tắt đồ thị thành văn bản gọn để nhét vào prompt: gom node theo loại,"""
|
||||
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):
|
||||
"""Các node có tên xuất hiện trong câu trả lời — dùng để gắn link nguồn kiểm"""
|
||||
graph = self._renderer.graph
|
||||
if graph is None or not text:
|
||||
return []
|
||||
found: dict = {}
|
||||
for n in 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."""
|
||||
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)
|
||||
text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text)
|
||||
text = re.sub(rf"(?<![\w`/\\.\]\)]){esc}(?![\w`\]\(])", f"[{tok}]({href})", text)
|
||||
return text
|
||||
|
||||
def _render_answer(self) -> None:
|
||||
"""Vẽ câu trả lời dạng markdown kèm danh sách nguồn bấm mở được."""
|
||||
text = self._answer
|
||||
sources = self._matched_sources(text)
|
||||
if sources:
|
||||
text = self._linkify_files(text, sources)
|
||||
lines = [text, "", "---", f"**{tr('structure.related_sources')}**"]
|
||||
for path, (kind, label, rel) in sources:
|
||||
href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded)
|
||||
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:
|
||||
"""Bấm một link trong khung chi tiết: là tệp thì mở tệp, còn lại mở bằng trình
|
||||
duyệt.
|
||||
"""
|
||||
if url.isLocalFile():
|
||||
p = url.toLocalFile()
|
||||
if Path(p).is_file():
|
||||
open_location(p)
|
||||
else:
|
||||
open_folder(p)
|
||||
|
||||
def _ask(self) -> None:
|
||||
"""Gửi câu hỏi về đồ thị tới model, chạy ở luồng nền."""
|
||||
question = self.ask_edit.text().strip()
|
||||
if not question:
|
||||
return
|
||||
from cowork_local.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
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
self.status_message.emit(tr("structure.scan_first"))
|
||||
return
|
||||
context = self._graph_context(graph)
|
||||
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._renderer.active_project_id
|
||||
selected_nodes = self._renderer.selected_node_data()
|
||||
selected_context = self._selection_context(selected_nodes, graph)
|
||||
|
||||
def job(worker: AgentWorker):
|
||||
"""Chạy nền: ghép ngữ cảnh đồ thị + nội dung tệp đã trích rồi gọi model."""
|
||||
provider = self.ctx.build_active_provider()
|
||||
system = self._system_prompt(skill_prefix, active_project_id)
|
||||
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}"
|
||||
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 cowork_local.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()
|
||||
|
||||
@staticmethod
|
||||
def _selection_context(selected_nodes, graph) -> str:
|
||||
"""Phần ngữ cảnh thêm cho các node người dùng đang chọn — câu hỏi "cái này là"""
|
||||
if not selected_nodes:
|
||||
return ""
|
||||
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}")
|
||||
connected_ids = set()
|
||||
for nd in selected_nodes:
|
||||
for edge in 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 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})")
|
||||
return "\n".join(node_lines)
|
||||
|
||||
@staticmethod
|
||||
def _system_prompt(skill_prefix: str, active_project_id: str) -> str:
|
||||
"""Prompt hệ thống cho khung hỏi-đáp."""
|
||||
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 cowork_local.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
|
||||
return system
|
||||
|
||||
def _on_ask_event(self, ev: dict) -> None:
|
||||
"""Nhận từng mẩu trả lời đang phát dần và vẽ dần vào khung chi tiết."""
|
||||
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.
|
||||
"""Trả lời xong: giữ lại nội dung tệp đã trích để câu hỏi sau không phải trích"""
|
||||
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[str]:
|
||||
"""File paths to read for a question: the SELECTED file nodes if
|
||||
any, else every file node in the graph (capped downstream)."""
|
||||
graph = self._renderer.graph
|
||||
if graph is None:
|
||||
return []
|
||||
sel = self._renderer.selected_node_data()
|
||||
nodes = sel or list(graph.nodes)
|
||||
out, seen = [], set()
|
||||
for nd in nodes:
|
||||
p = (getattr(nd, "path", "") or "").strip()
|
||||
if p and p not in seen and Path(p).is_file():
|
||||
seen.add(p)
|
||||
out.append(p)
|
||||
return out
|
||||
|
||||
def _extract_tmp_dir(self) -> Path:
|
||||
"""Thư mục tạm chứa nội dung tệp đã trích, tạo lười ở lần cần đầu tiên."""
|
||||
if self._extract_dir is None:
|
||||
import tempfile
|
||||
from cowork_local.config import CONFIG_DIR
|
||||
base = CONFIG_DIR / "tmp" / "graphrag_extract"
|
||||
base.mkdir(parents=True, exist_ok=True)
|
||||
self._extract_dir = Path(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)
|
||||
|
||||
|
||||
__all__ = ["GraphQaWidget"]
|
||||
@@ -0,0 +1,391 @@
|
||||
"""GraphRenderer — the toolbar, scan/render pipeline, and graph/messages
|
||||
stack of GraphRAG (R08-T14, extracted from
|
||||
``ui/structure_graph_view.py::StructureGraphView``, lines 188-286/336-661/
|
||||
664-702 of the original 1035-line file — everything except the right-side
|
||||
Q&A panel, which is ``graph_qa_widget.py::GraphQaWidget``).
|
||||
|
||||
Talks to the Q&A panel only through signals (:attr:`node_selected`,
|
||||
:attr:`graph_rendered`) and a small read API (:attr:`graph`,
|
||||
:meth:`selected_node_data`, :attr:`active_project_id`) — this class has no
|
||||
idea ``GraphQaWidget`` exists, matching how
|
||||
``presentation/folder/document_preview_manager.py`` doesn't know about the
|
||||
AI-edit panel either.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from pathlib import Path
|
||||
from typing import List, Optional
|
||||
|
||||
from PySide6.QtCore import QPointF, Qt, QTimer, QUrl, Signal
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import (
|
||||
QComboBox, QFileDialog, QGraphicsScene, QHBoxLayout, QLineEdit,
|
||||
QPushButton, QStackedWidget, QTabBar, QVBoxLayout, QWidget,
|
||||
)
|
||||
|
||||
from cowork_local.core.worker import AgentWorker
|
||||
from cowork_local.i18n import on_language_changed, tr
|
||||
from cowork_local.presentation.graph import graph_export
|
||||
from cowork_local.presentation.graph.graph_messages_view import GraphMessagesView
|
||||
from cowork_local.presentation.graph.graph_scene_builder import build_scene
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Bridge, _Edge, _GraphView, _Node
|
||||
from cowork_local.presentation.shared import HAS_WEB_ENGINE
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.ui.icons import icon
|
||||
|
||||
|
||||
class GraphRenderer(QWidget):
|
||||
"""Nửa "đồ thị" của màn GraphRAG: thanh công cụ, khung xem và vòng đời quét."""
|
||||
status_message = Signal(str)
|
||||
node_selected = Signal(object) # a node's .data, whenever the scene selection changes
|
||||
graph_rendered = Signal() # a scan just finished rendering (fresh OR re-fit)
|
||||
raw_json_ready = Signal(str) # pre-formatted HTML for a clicked Messages entry
|
||||
project_changed = Signal() # a DIFFERENT project was selected (or cleared)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Dựng thanh công cụ, khung xem 2D và chỗ chờ cho khung D3.
|
||||
|
||||
Khung D3 KHÔNG được dựng ở đây: ``QWebEngineView`` mất vài trăm mili giây
|
||||
để khởi tạo, mà phần lớn người dùng mở màn này chỉ để xem cảnh Qt. Nó được
|
||||
dựng muộn trong :meth:`_ensure_web`.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
self._worker: Optional[AgentWorker] = None
|
||||
self._node_items: List[_Node] = []
|
||||
self._edge_items: List[_Edge] = []
|
||||
self._centroid = QPointF(0, 0)
|
||||
self._graph = None
|
||||
self._needs_scan = False
|
||||
self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite)
|
||||
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)
|
||||
root.setContentsMargins(0, 0, 0, 0)
|
||||
|
||||
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)
|
||||
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.
|
||||
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)
|
||||
|
||||
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)
|
||||
self.web = None
|
||||
self._bridge = None
|
||||
self._channel = None
|
||||
root.addWidget(self._stack, 1)
|
||||
# "Messages" view: all conversation messages grouped BY DAY, shown as
|
||||
# JSON — a separate concern composed in (see graph_messages_view.py).
|
||||
self._messages = GraphMessagesView(self)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Áp lại chữ theo ngôn ngữ đang chọn cho thanh công cụ và tên tab."""
|
||||
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"))
|
||||
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.project_combo.setToolTip(tr("structure.project_tooltip"))
|
||||
self._refresh_project_combo()
|
||||
|
||||
# ---- public read API for GraphQaWidget ----------------------------------- #
|
||||
@property
|
||||
def graph(self):
|
||||
"""Đồ thị đã quét gần nhất; ``None`` nếu chưa quét lần nào."""
|
||||
return self._graph
|
||||
|
||||
@property
|
||||
def active_project_id(self) -> str:
|
||||
"""Id dự án đang chọn trong bộ chọn; '' nếu chưa chọn."""
|
||||
return self._active_project_id
|
||||
|
||||
def selected_node_data(self) -> list:
|
||||
"""Dữ liệu của các node đang được chọn trên cảnh — panel Hỏi đáp đọc cái này
|
||||
để biết người dùng đang hỏi về cái gì.
|
||||
"""
|
||||
return [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)]
|
||||
|
||||
# ---- project sandbox lock ------------------------------------------------- #
|
||||
def _refresh_project_combo(self) -> None:
|
||||
"""Nạp lại danh sách project vào bộ chọn, giữ nguyên project đang chọn."""
|
||||
from cowork_local.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:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
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:
|
||||
"""Áp trạng thái khoá: đường dẫn chuyển sang chỉ đọc và trỏ vào thư mục"""
|
||||
from cowork_local.core.projects import load_project
|
||||
|
||||
pid = self.project_combo.currentData() or ""
|
||||
project_changed = pid != self._active_project_id
|
||||
self._active_project_id = pid
|
||||
locked = bool(pid)
|
||||
self.path_edit.setReadOnly(locked)
|
||||
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:
|
||||
self.project_changed.emit() # GraphQaWidget drops its temp extraction cache
|
||||
# Mark it and scan on the next visit rather than now — see
|
||||
# auto_scan_and_fit()'s docstring for why.
|
||||
self._needs_scan = True
|
||||
|
||||
# ---- helpers ---------------------------------------------------------------- #
|
||||
def _pick(self) -> None:
|
||||
"""Mở hộp thoại chọn thư mục gốc để quét."""
|
||||
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:
|
||||
"""Hẹn quét lại sau khi có thay đổi trên đĩa."""
|
||||
if self._graph is None:
|
||||
self._needs_scan = True
|
||||
return
|
||||
self._rescan_timer.start()
|
||||
|
||||
# ---- Messages (by day, as JSON) — see graph_messages_view.py --------------- #
|
||||
def _on_view_tab(self, index: int) -> None:
|
||||
"""Đổi giữa tab đồ thị và tab Tin nhắn."""
|
||||
self._messages.on_view_tab(index)
|
||||
|
||||
def _toggle_messages(self) -> None:
|
||||
"""Kept for callers that still ask for a flip (e.g. keyboard paths)."""
|
||||
self._messages.toggle()
|
||||
|
||||
# ---- prewarm / scan lifecycle -------------------------------------------------- #
|
||||
def prewarm(self) -> None:
|
||||
"""Pay for the graph view before it is clicked on, not during."""
|
||||
if not HAS_WEB_ENGINE 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()
|
||||
|
||||
def _ensure_web(self) -> None:
|
||||
"""Dựng khung D3 nếu máy có WebEngine và chưa dựng.
|
||||
|
||||
Gọi được nhiều lần — lần thứ hai trở đi không làm gì. Máy không có
|
||||
WebEngine thì bỏ qua im lặng và người dùng ở lại với cảnh Qt.
|
||||
"""
|
||||
if self.web is not None or not HAS_WEB_ENGINE:
|
||||
return
|
||||
from PySide6.QtWebChannel import QWebChannel
|
||||
from PySide6.QtWebEngineWidgets import QWebEngineView
|
||||
|
||||
self.web = QWebEngineView()
|
||||
self.web.setHtml(
|
||||
f"<body style='margin:0;background:{current_palette().bg}'></body>")
|
||||
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:
|
||||
"""Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần."""
|
||||
self._ensure_web()
|
||||
if not self.path_edit.text().strip():
|
||||
return
|
||||
if self._worker is not None and self._worker.isRunning():
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
if self._graph is not None and not self._needs_scan:
|
||||
self._fit()
|
||||
self.graph_rendered.emit()
|
||||
return
|
||||
self._needs_scan = False
|
||||
self._scan()
|
||||
|
||||
# ---- scan --------------------------------------------------------------------- #
|
||||
def _scan(self) -> None:
|
||||
"""Quét thư mục gốc ở luồng nền rồi vẽ kết quả.
|
||||
|
||||
Mỗi lượt quét mang một số thứ tự ``_scan_seq``; kết quả về mà số không còn
|
||||
khớp thì bị bỏ, để lượt quét cũ không đè lên lượt mới.
|
||||
"""
|
||||
path = self.path_edit.text().strip() or str(Path.cwd())
|
||||
mode = "files"
|
||||
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):
|
||||
"""Chạy nền: dựng đồ thị từ thư mục rồi tính toạ độ.
|
||||
|
||||
Ưu tiên đọc từ codebase-memory nếu có sẵn — nhanh hơn nhiều so với quét lại
|
||||
cả cây thư mục; không có thì quét trực tiếp.
|
||||
"""
|
||||
from cowork_local.core.structure_graph import (
|
||||
build_from_codebase_memory, build_from_directory, force_layout,
|
||||
)
|
||||
if use_cmem:
|
||||
from cowork_local.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:
|
||||
"""Vẽ kết quả quét ra khung Qt 2D (và ra D3 nếu đang bật).
|
||||
|
||||
Lượt quét cũ về muộn thì bỏ: ``seq`` của nó không còn khớp
|
||||
``_scan_seq``. Không chặn ở đây thì người dùng đổi dự án nhanh sẽ thấy
|
||||
đồ thị của dự án TRƯỚC đè lên dự án đang chọn.
|
||||
"""
|
||||
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._node_items, self._edge_items, self._centroid = build_scene(
|
||||
self.scene, graph, pos, current_palette().bg)
|
||||
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.graph_rendered.emit()
|
||||
|
||||
def _render_d3(self) -> None:
|
||||
"""Nạp lại trang D3 từ đồ thị hiện có. Lỗi dựng HTML chỉ báo lên thanh trạng
|
||||
thái chứ không ném ra — cảnh Qt vẫn xem được.
|
||||
"""
|
||||
if self.web is None or self._graph is None:
|
||||
return
|
||||
from cowork_local.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:
|
||||
"""Chọn một node trên cảnh thì báo cho panel Hỏi đáp biết."""
|
||||
for item in self.scene.selectedItems():
|
||||
if isinstance(item, _Node):
|
||||
self.node_selected.emit(item.data)
|
||||
return
|
||||
|
||||
def _fit(self) -> None:
|
||||
"""Canh toàn bộ đồ thị vừa khung nhìn."""
|
||||
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:
|
||||
"""Xuất khung đang xem ra PNG.
|
||||
|
||||
Cách chụp tuỳ khung nào đang hiện — xem ``graph_export.py``.
|
||||
"""
|
||||
graph_export.export_png(
|
||||
parent=self,
|
||||
showing=self._stack.currentWidget(),
|
||||
web=self.web,
|
||||
is_web_visible=self._stack.currentWidget() is self.web,
|
||||
status=self.status_message.emit,
|
||||
)
|
||||
|
||||
|
||||
__all__ = ["GraphRenderer"]
|
||||
@@ -0,0 +1,95 @@
|
||||
"""Dựng các item Qt cho khung đồ thị 2D — tách khỏi ``graph_renderer.py``.
|
||||
|
||||
Đây là phần "biến dữ liệu thành hình" của GraphRAG: nhận một đồ thị đã quét
|
||||
cùng bảng toạ độ, đổ ``_Node``/``_Edge`` vào ``QGraphicsScene`` rồi trả lại
|
||||
danh sách item và trọng tâm cho bên gọi.
|
||||
|
||||
Tách ra vì nó không cần biết gì về widget: không đọc thuộc tính nào của
|
||||
``GraphRenderer``, không phát tín hiệu, không chạm cấu hình. Nhờ thế mà thử
|
||||
được bằng một ``QGraphicsScene`` trần, và ``graph_renderer.py`` bớt đi phần
|
||||
duy nhất trong nó có tính toán hình học thật sự.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
from typing import Dict, List, Tuple
|
||||
|
||||
from PySide6.QtCore import QPointF
|
||||
from PySide6.QtGui import QColor
|
||||
from PySide6.QtWidgets import QGraphicsScene
|
||||
|
||||
from cowork_local.presentation.graph.graph_scene_items import _Edge, _Node
|
||||
|
||||
|
||||
def node_degrees(graph) -> Dict[str, int]:
|
||||
"""Số cạnh chạm vào từng node.
|
||||
|
||||
Dùng để định cỡ node: node càng nhiều liên kết thì vẽ càng to, nên mắt
|
||||
người nhìn vào là thấy ngay đâu là đầu mối của cả đồ thị.
|
||||
"""
|
||||
degree = {n.id: 0 for n in graph.nodes}
|
||||
for edge in graph.edges:
|
||||
if edge.source in degree:
|
||||
degree[edge.source] += 1
|
||||
if edge.target in degree:
|
||||
degree[edge.target] += 1
|
||||
return degree
|
||||
|
||||
|
||||
def node_radius(degree: int) -> int:
|
||||
"""Bán kính vẽ của một node theo bậc của nó.
|
||||
|
||||
Lấy căn bậc hai chứ không lấy tuyến tính: bậc tăng gấp bốn thì bán kính
|
||||
mới gấp đôi, nhờ vậy DIỆN TÍCH mới tỉ lệ với bậc — đó mới là thứ mắt
|
||||
người thật sự so sánh. Chặn trên ở 28 để một node trung tâm không nuốt
|
||||
mất phần còn lại của đồ thị.
|
||||
"""
|
||||
return int(8 + min(20, 2.2 * math.sqrt(degree)))
|
||||
|
||||
|
||||
def build_scene(
|
||||
scene: QGraphicsScene, graph, pos: Dict[str, Tuple[float, float]], bg: str
|
||||
) -> Tuple[List[_Node], List[_Edge], QPointF]:
|
||||
"""Xoá sạch ``scene`` rồi dựng lại toàn bộ node và cạnh của ``graph``.
|
||||
|
||||
``pos`` là toạ độ đã tính sẵn ở luồng nền (``{node_id: (x, y)}``); node
|
||||
không có trong đó rơi về gốc toạ độ thay vì bị bỏ, để không im lặng đánh
|
||||
mất dữ liệu.
|
||||
|
||||
Cạnh chỉ được vẽ khi CẢ HAI đầu đều có item — đồ thị bị cắt bớt
|
||||
(``truncated``) hay dữ liệu lệch có thể trỏ tới node không tồn tại, và
|
||||
một ``_Edge`` treo lơ lửng sẽ làm hỏng cả phép tính khung nhìn.
|
||||
|
||||
Trả về ``(danh sách node, danh sách cạnh, trọng tâm)``. Trọng tâm là
|
||||
trung bình cộng toạ độ các node, dùng làm tâm khi thu phóng.
|
||||
"""
|
||||
scene.clear()
|
||||
scene.setBackgroundBrush(QColor(bg))
|
||||
|
||||
degree = node_degrees(graph)
|
||||
items: Dict[str, _Node] = {}
|
||||
node_items: List[_Node] = []
|
||||
sx = sy = 0.0
|
||||
for node in graph.nodes:
|
||||
item = _Node(node, node_radius(degree.get(node.id, 0)))
|
||||
x, y = pos.get(node.id, (0, 0))
|
||||
item.setPos(x, y)
|
||||
scene.addItem(item)
|
||||
items[node.id] = item
|
||||
node_items.append(item)
|
||||
sx += x
|
||||
sy += y
|
||||
|
||||
edge_items: List[_Edge] = []
|
||||
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", ""))
|
||||
scene.addItem(e)
|
||||
edge_items.append(e)
|
||||
|
||||
n = max(1, len(node_items))
|
||||
return node_items, edge_items, QPointF(sx / n, sy / n)
|
||||
|
||||
|
||||
__all__ = ["build_scene", "node_degrees", "node_radius"]
|
||||
@@ -0,0 +1,176 @@
|
||||
"""Native QGraphicsScene primitives for the fallback (non-WebEngine) graph
|
||||
view (R08-T14, split out of ``graph_renderer.py`` to keep it under the
|
||||
400-line cap; originally ``ui/structure_graph_view.py``, lines 65-186 of the
|
||||
original 1035-line file: ``_Bridge``, ``_Edge``, ``_Node``, ``_GraphView``).
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import math
|
||||
|
||||
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 cowork_local.core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS
|
||||
from cowork_local.theme import current_palette
|
||||
from cowork_local.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
|
||||
"""Cầu nối JavaScript → Python: Shift+click một node trên khung D3 thì mở thư
|
||||
mục hoặc link lưu trữ của nó.
|
||||
"""
|
||||
if path:
|
||||
open_location(path)
|
||||
|
||||
|
||||
class _Edge(QGraphicsLineItem):
|
||||
"""Một cạnh trên khung Qt 2D, kèm nhãn tên quan hệ ở điểm giữa.
|
||||
|
||||
Màu cạnh lấy theo LOẠI quan hệ (contains/defines/method…) để nhìn đồ thị
|
||||
là biết mỗi liên kết nghĩa là gì; cạnh không có loại thì mượn màu nhạt của
|
||||
node nguồn. Nằm ở z=-1, tức dưới mọi node.
|
||||
"""
|
||||
def __init__(self, a: "_Node", b: "_Node", type_: str = ""):
|
||||
"""Một cạnh trên khung đồ thị, tô màu theo LOẠI quan hệ (chứa/định nghĩa/phương
|
||||
thức…) để nhìn là biết liên kết ấy nghĩa gì; cạnh không có loại thì lấy màu
|
||||
của node nguồn.
|
||||
"""
|
||||
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:
|
||||
"""Vẽ lại đường thẳng theo vị trí hai node và đặt nhãn vào đúng điểm giữa."""
|
||||
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):
|
||||
"""Một node hình tròn; bán kính do chỗ gọi tính theo bậc của node.
|
||||
|
||||
``NODE_KIND_COLORS`` là mã màu theo LOẠI dữ liệu (mỗi loại một sắc), cố ý
|
||||
giữ nguyên ở cả theme sáng lẫn tối để một loại luôn là một màu. Chỉ phần
|
||||
khung viền/chữ mới đi theo theme.
|
||||
"""
|
||||
def __init__(self, data, radius: int):
|
||||
"""Một node hình tròn, bán kính theo số liên kết.
|
||||
|
||||
Màu theo loại node là mã hoá dữ liệu chứ không phải trang trí, nên cố định
|
||||
qua mọi giao diện — cùng một loại luôn cùng một màu.
|
||||
"""
|
||||
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
|
||||
"""Kéo node thì kéo theo mọi cạnh dính vào nó."""
|
||||
if change == QGraphicsEllipseItem.ItemPositionHasChanged:
|
||||
for edge in self.edges:
|
||||
edge.adjust()
|
||||
return super().itemChange(change, value)
|
||||
|
||||
|
||||
class _GraphView(QGraphicsView):
|
||||
"""Khung xem đồ thị Qt 2D: lăn chuột để phóng, kéo nền để dời khung."""
|
||||
def __init__(self, scene):
|
||||
"""Khung xem đồ thị: kéo bằng chuột giữa để di chuyển, không kéo chọn vùng."""
|
||||
super().__init__(scene)
|
||||
self.setDragMode(QGraphicsView.NoDrag)
|
||||
self._panning = False
|
||||
self._pan_start = QPointF()
|
||||
|
||||
def wheelEvent(self, e): # noqa: N802
|
||||
"""Lăn chuột phóng to/thu nhỏ một nấc 1,15 lần."""
|
||||
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
|
||||
"""Nhấn trái vào chỗ TRỐNG thì bắt đầu kéo khung; nhấn trúng node thì để Qt
|
||||
xử lý như chọn/kéo node bình thường.
|
||||
"""
|
||||
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
|
||||
"""Đang kéo khung: dời hai thanh cuộn ngược chiều con trỏ."""
|
||||
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
|
||||
"""Thả chuột: kết thúc kéo khung, trả con trỏ về bình thường."""
|
||||
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)
|
||||
|
||||
|
||||
__all__ = ["_Bridge", "_Edge", "_Node", "_GraphView"]
|
||||
@@ -0,0 +1,98 @@
|
||||
"""StructureGraphView shell (R08-T14) — assembles
|
||||
``graph_renderer.py::GraphRenderer`` and
|
||||
``graph_qa_widget.py::GraphQaWidget`` behind the splitter that used to be
|
||||
inline in ``ui/structure_graph_view.py::StructureGraphView.__init__`` (lines
|
||||
188-343 of the original 1035-line file), and forwards the public methods
|
||||
``app.py``/``ui/workspace_tab.py`` call: ``schedule_rescan``,
|
||||
``auto_scan_and_fit``, ``set_project``, ``prewarm``.
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
from PySide6.QtCore import Qt, Signal
|
||||
from PySide6.QtWidgets import QSplitter, QVBoxLayout, QWidget
|
||||
|
||||
from cowork_local.i18n import on_language_changed
|
||||
from cowork_local.presentation.graph.graph_qa_widget import GraphQaWidget
|
||||
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
|
||||
from cowork_local.state import AppContext
|
||||
from cowork_local.ui.widgets import CollapseStrip
|
||||
|
||||
_COLLAPSED_SIZES_HINT = (840, 320) # matches the original single-class default
|
||||
|
||||
|
||||
class StructureGraphView(QWidget):
|
||||
"""Màn GraphRAG: ghép khung đồ thị (:class:`GraphRenderer`) và khung hỏi-đáp
|
||||
(:class:`GraphQaWidget`) vào một splitter.
|
||||
|
||||
Vỏ này chỉ lắp ráp và chuyển tiếp bốn phương thức mà ``app.py`` và
|
||||
``ui/workspace_tab.py`` gọi tới; toàn bộ phần việc thật nằm ở hai widget con.
|
||||
"""
|
||||
status_message = Signal(str)
|
||||
|
||||
def __init__(self, ctx: AppContext):
|
||||
"""Ghép hai nửa của màn GraphRAG: bên trái là đồ thị, bên phải là panel Hỏi
|
||||
đáp.
|
||||
"""
|
||||
super().__init__()
|
||||
self.ctx = ctx
|
||||
|
||||
root = QVBoxLayout(self)
|
||||
self.renderer = GraphRenderer(ctx)
|
||||
self.renderer.status_message.connect(self.status_message.emit)
|
||||
self.qa = GraphQaWidget(ctx, self.renderer)
|
||||
self.qa.status_message.connect(self.status_message.emit)
|
||||
self.qa.collapse_changed.connect(self._on_qa_collapse_changed)
|
||||
|
||||
self._split = QSplitter(Qt.Horizontal)
|
||||
self._split.addWidget(self.renderer)
|
||||
self._split.addWidget(self.qa)
|
||||
self._split.setChildrenCollapsible(False)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
root.addWidget(self._split, 1)
|
||||
|
||||
on_language_changed(self._retranslate)
|
||||
|
||||
def _retranslate(self) -> None:
|
||||
"""Chuyển lệnh dịch lại xuống cho cả hai widget con."""
|
||||
self.renderer._retranslate()
|
||||
self.qa.retranslate()
|
||||
|
||||
def _on_qa_collapse_changed(self, collapsed: bool) -> None:
|
||||
"""Gập/mở khung hỏi-đáp: gập thì thu về đúng bề rộng dải nắm, mở thì trả
|
||||
splitter về tỉ lệ mặc định.
|
||||
"""
|
||||
strip_w = CollapseStrip.WIDTH + 2
|
||||
if collapsed:
|
||||
self.qa.setMaximumWidth(strip_w)
|
||||
sizes = self._split.sizes()
|
||||
if len(sizes) == 2:
|
||||
self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w])
|
||||
else:
|
||||
self.qa.setMaximumWidth(16777215)
|
||||
self._split.setSizes(list(_COLLAPSED_SIZES_HINT))
|
||||
|
||||
# ---- public API (app.py / ui/workspace_tab.py) --------------------------- #
|
||||
def schedule_rescan(self, path: str = "") -> None:
|
||||
"""Hẹn quét lại đồ thị sau khi thư mục có thay đổi."""
|
||||
self.renderer.schedule_rescan(path)
|
||||
|
||||
def auto_scan_and_fit(self) -> None:
|
||||
"""Vào màn GraphRAG: hiện đồ thị, chỉ quét lại khi thật sự cần."""
|
||||
self.renderer.auto_scan_and_fit()
|
||||
|
||||
def set_project(self, project_id: str) -> None:
|
||||
"""Khoá phạm vi quét vào một project (chuỗi rỗng là bỏ khoá)."""
|
||||
self.renderer.set_project(project_id)
|
||||
|
||||
def prewarm(self) -> None:
|
||||
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
|
||||
self.renderer.prewarm()
|
||||
|
||||
def hideEvent(self, e): # noqa: N802
|
||||
# Leaving the GraphRAG tab → drop the temporary extracted info.
|
||||
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
|
||||
self.qa.clear_extracts()
|
||||
super().hideEvent(e)
|
||||
|
||||
|
||||
__all__ = ["StructureGraphView"]
|
||||
Reference in New Issue
Block a user