From 7607f4403060078d0b6c290ec8ce7a7d86bb86c1 Mon Sep 17 00:00:00 2001 From: thanhnv Date: Wed, 16 Sep 2026 00:05:34 +0900 Subject: [PATCH] chore: checkpoint current performance and UI changes --- core/doc_extract.py | 27 ++++++--- core/history.py | 31 +++++++++- docs/PERFORMANCE_CHECKPOINT.md | 23 ++++++++ performance.py | 31 ++++++++++ presentation/chat/chat_agents.py | 21 +++++-- presentation/chat/chat_history_widget.py | 13 ++++- presentation/monitoring/monitoring_tab.py | 14 +++-- presentation/shell/main_window.py | 7 +-- presentation/shell/session_events.py | 12 +++- ui/workspace_tab.py | 69 ++++++++++++++++++----- 10 files changed, 205 insertions(+), 43 deletions(-) create mode 100644 docs/PERFORMANCE_CHECKPOINT.md create mode 100644 performance.py diff --git a/core/doc_extract.py b/core/doc_extract.py index 04ffd74..1cbadb1 100644 --- a/core/doc_extract.py +++ b/core/doc_extract.py @@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None, capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count before that cap, so a caller can report how many were skipped.""" exts = exts or INPUT_EXTS + # Do not sort an unbounded recursive tree merely to return a small prefix. + # The caller receives a stable lexical order for the bounded result, while + # traversal stops as soon as the configured file budget is reached. + files: list[Path] = [] + total = 0 try: - matched = sorted( - f for f in folder.rglob("*") - if f.is_file() - and not any(part.startswith(".") for part in f.relative_to(folder).parts) - and f.suffix.lower() in exts - ) + for f in folder.rglob("*"): + if not f.is_file(): + continue + try: + relative = f.relative_to(folder) + except ValueError: + continue + if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts: + continue + total += 1 + if max_files <= 0 or len(files) < max_files: + files.append(f) except OSError: return [], 0 - files = matched if max_files <= 0 else matched[:max_files] - return files, len(matched) + files.sort(key=lambda p: str(p).lower()) + return files, total def find_soffice() -> str | None: diff --git a/core/history.py b/core/history.py index 5d14fc6..a9e31a3 100644 --- a/core/history.py +++ b/core/history.py @@ -16,6 +16,17 @@ from datetime import datetime from pathlib import Path from typing import Any, Dict, List +from ..performance import span + +_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {} + + +def _invalidate_history_cache(directory: Path) -> None: + prefix = str(Path(directory).resolve()) + for key in list(_LIST_CACHE): + if key[0] == prefix: + _LIST_CACHE.pop(key, None) + def new_session_id() -> str: """Id phiên mới theo mốc thời gian, chính xác tới mili giây.""" @@ -78,6 +89,7 @@ def save_conversation( # R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py. from ..infrastructure.persistence.json.atomic_write import write_json write_json(path, payload) + _invalidate_history_cache(directory) return path @@ -85,6 +97,7 @@ def delete_conversation(path) -> None: """Xoá file hội thoại; không có thì bỏ qua.""" try: Path(path).unlink() + _invalidate_history_cache(Path(path).parent) except OSError: pass @@ -96,6 +109,7 @@ def rename_conversation(path, new_title: str) -> None: data = load_conversation(path) data["title"] = new_title write_json(Path(path), data) + _invalidate_history_cache(Path(path).parent) def set_pinned(path, pinned: bool) -> None: @@ -105,6 +119,7 @@ def set_pinned(path, pinned: bool) -> None: data = load_conversation(path) data["pinned"] = bool(pinned) write_json(Path(path), data) + _invalidate_history_cache(Path(path).parent) def load_conversation(path: Path) -> Dict[str, Any]: @@ -197,8 +212,16 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis if not directory or not directory.exists(): return [] q = (query or "").strip().lower() + try: + cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns) + except OSError: + return [] + cached = _LIST_CACHE.get(cache_key) + if cached is not None: + return [dict(item) for item in cached] items: List[Dict[str, Any]] = [] - for path in directory.glob("*.json"): + with span("history.list", query=bool(q)): + for path in directory.glob("*.json"): try: data = json.loads(path.read_text(encoding="utf-8")) except (OSError, json.JSONDecodeError): @@ -221,4 +244,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis }) # pinned first, then most recent items.sort(key=lambda d: (not d["pinned"], -d["mtime"])) + _LIST_CACHE[cache_key] = [dict(item) for item in items] + # Keep this bounded; old directory signatures become unreachable after a + # write and should not grow process memory forever. + if len(_LIST_CACHE) > 256: + for old in list(_LIST_CACHE)[:64]: + _LIST_CACHE.pop(old, None) return items diff --git a/docs/PERFORMANCE_CHECKPOINT.md b/docs/PERFORMANCE_CHECKPOINT.md new file mode 100644 index 0000000..5d3311d --- /dev/null +++ b/docs/PERFORMANCE_CHECKPOINT.md @@ -0,0 +1,23 @@ +Source HEAD: db80289 (preserved) +Branch: perf/fsg-performance + +Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine). + +Packets completed: +- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition. +- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts. +- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact. +- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set. +- P5: instrument monitoring log refresh; existing 30-day bounded window retained. +- P6: defer provider model discovery to the first Qt event-loop turn. + +After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction). +Streaming render count is now bounded by batch cadence rather than token count. +Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine. + +Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work). +Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead. + +Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass. +Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk. +Performance initiative status: closed for this pass. diff --git a/performance.py b/performance.py new file mode 100644 index 0000000..1a5884b --- /dev/null +++ b/performance.py @@ -0,0 +1,31 @@ +"""Tiny opt-in performance tracing helpers. + +Tracing is disabled by default and emits only timings/counts, never prompts, +credentials, file contents, or provider payloads. +""" +from __future__ import annotations + +import logging +import os +import time +from contextlib import contextmanager + +_LOG = logging.getLogger("cowork.performance") + + +def enabled() -> bool: + return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"} + + +@contextmanager +def span(name: str, **fields): + if not enabled(): + yield + return + started = time.perf_counter() + try: + yield + finally: + elapsed = (time.perf_counter() - started) * 1000.0 + safe = " ".join(f"{k}={v}" for k, v in fields.items()) + _LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "") diff --git a/presentation/chat/chat_agents.py b/presentation/chat/chat_agents.py index d280edb..a9a8ae0 100644 --- a/presentation/chat/chat_agents.py +++ b/presentation/chat/chat_agents.py @@ -115,10 +115,23 @@ class ChatAgentsMixin: if err: self.status_message.emit(tr("chatpanel.agent_list_error", err=err)) - w = AgentWorker(job) - w.finished_ok.connect(done) - self._agent_worker = w - w.start() + # Model discovery can involve a provider/network request. Constructing + # the chat panel during startup must not wait for it; schedule it after + # the first event-loop turn so the initial shell can paint immediately. + from PySide6.QtCore import QTimer + + if getattr(self, "_agent_refresh_pending", False): + return + self._agent_refresh_pending = True + + def start_worker() -> None: + self._agent_refresh_pending = False + w = AgentWorker(job) + w.finished_ok.connect(done) + self._agent_worker = w + w.start() + + QTimer.singleShot(0, start_worker) def _populate_agents(self, models, keep: str) -> None: """Đổ danh sách vào bộ chọn Agent. diff --git a/presentation/chat/chat_history_widget.py b/presentation/chat/chat_history_widget.py index b1898ee..223377d 100644 --- a/presentation/chat/chat_history_widget.py +++ b/presentation/chat/chat_history_widget.py @@ -51,6 +51,8 @@ class MessageBubble(QFrame): super().__init__() self.role = role self._text = "" + self._stream_pending = False + self._render_count = 0 self._collapsible = collapsible self._title = title self._head = None @@ -164,12 +166,20 @@ class MessageBubble(QFrame): def append_delta(self, delta: str) -> None: """Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown.""" self._text += delta - self.set_markdown(self._text) + if not self._stream_pending: + self._stream_pending = True + QTimer.singleShot(40, self.flush_stream) + + def flush_stream(self) -> None: + if self._stream_pending: + self._stream_pending = False + self.set_markdown(self._text) def set_markdown(self, text: str) -> None: """Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao.""" self._text = text self.body.setMarkdown(text) + self._render_count += 1 self._autosize() if self._collapsible: self._update_head() @@ -393,4 +403,3 @@ class ChatView(QScrollArea): ChatHistoryWidget = ChatView __all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"] - diff --git a/presentation/monitoring/monitoring_tab.py b/presentation/monitoring/monitoring_tab.py index 1427c39..d808f65 100644 --- a/presentation/monitoring/monitoring_tab.py +++ b/presentation/monitoring/monitoring_tab.py @@ -19,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid from ...core import audit_log from ...i18n import on_language_changed, tr from ...state import AppContext +from ...performance import span from .tabs.action_logs_tab import ActionLogsTab from .tabs.agent_status_tab import AgentStatusTab from .tabs.mcp_tab import McpTab @@ -282,12 +283,13 @@ class MonitoringTab(QWidget): """ start = date.today() - timedelta(days=_LOG_WINDOW_DAYS) shared_dir = self.ctx.config.shared_dir - if shared_dir: - from ...core import telemetry_shared - shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) - if shared_events: - return shared_events - return audit_log.load_events(start=start) + with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS): + if shared_dir: + from ...core import telemetry_shared + shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) + if shared_events: + return shared_events + return audit_log.load_events(start=start) def _apply_events_to_event_tabs(self, events: List[dict]) -> None: """Filters the ALREADY-LOADED event list (see ``_load_events`` — one diff --git a/presentation/shell/main_window.py b/presentation/shell/main_window.py index 4573bae..92fb1b9 100644 --- a/presentation/shell/main_window.py +++ b/presentation/shell/main_window.py @@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH from .tray_manager import TrayManager from ...state import AppContext from ...core.task_scheduler import TaskScheduler -from ...ui.cowork_tab import CoworkTab from ...ui.sidebar import HistorySidebar -from ..graph.structure_graph_view import StructureGraphView from ...ui.workspace_tab import WorkspaceTab @@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin, # Workspace screen (per selected project). GraphRAG's heavy # QtWebEngine is still built lazily on first display # (StructureGraphView._ensure_web). + from ...ui.cowork_tab import CoworkTab self.cowork = CoworkTab(ctx) - self.structure = StructureGraphView(ctx) - self.structure.status_message.connect(self.statusBar().showMessage) - self.cowork.output_changed.connect(self.structure.schedule_rescan) + self.structure = None self.cowork.status_message.connect(self.statusBar().showMessage) # Refresh History (list + running markers + current highlight) whenever a # conversation is created/updated or a turn finishes. diff --git a/presentation/shell/session_events.py b/presentation/shell/session_events.py index 8897747..d115749 100644 --- a/presentation/shell/session_events.py +++ b/presentation/shell/session_events.py @@ -42,7 +42,14 @@ class SessionEventsMixin: self.sidebar.refresh() self._refresh_rail_recents() # the rail shortcut follows the panel - QTimer.singleShot(0, _do) + # Coalesce bursts from turn/tool/history signals into one sidebar read. + timer = getattr(self, "_history_refresh_timer", None) + if timer is None: + timer = QTimer(self) + timer.setSingleShot(True) + timer.timeout.connect(_do) + self._history_refresh_timer = timer + timer.start(0) def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None: """Desktop notification for a finished scheduled task (toast always, tray balloon when the window isn't focused), then refresh History — @@ -106,4 +113,5 @@ class SessionEventsMixin: """Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục.""" self.sidebar.refresh() # History regroups by project self.cowork._apply_output_folder_label() # project may have been renamed - self.structure._refresh_project_combo() # GraphRAG's project lock list follows too + if getattr(self, "structure", None) is not None: + self.structure._refresh_project_combo() # GraphRAG's project lock list follows too diff --git a/ui/workspace_tab.py b/ui/workspace_tab.py index a929d92..e966929 100644 --- a/ui/workspace_tab.py +++ b/ui/workspace_tab.py @@ -202,27 +202,26 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): cpl.addWidget(self._cowork) self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_cowork")) - # Co4E — node-graph workflow studio (built-in flows, agents, skills, a - # runner + chat). Always available (not project-gated): its workflows - # live globally under ~/.cowork_local/co4e, not inside one project. - # Placed BEFORE GraphRAG in the tab order (user request). - from .co4e_tab import Co4ETab - - self._co4e = Co4ETab(self.ctx) - self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e")) + # Co4E and Folder are intentionally placeholders at startup. Their + # widget trees pull in a large amount of Qt/UI code, but neither is on + # the initial Project surface. The real page is created exactly once + # when its tab is first selected (see _ensure_heavy_tab). + self._co4e = None + self._folder = None + self._co4e_placeholder = QWidget() + self._folder_placeholder = QWidget() + self._co4e_tab_idx = self.tabs.addTab(self._co4e_placeholder, tr("workspace.tab_co4e")) self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip")) # Folder — a two-pane file explorer (tree + view/edit) placed right below # Co4E. Always available (not project-gated); its root follows the # selected project's workspace folder when one is chosen. - from ..presentation.folder.folder_tab import FolderTab + self._folder_tab_idx = self.tabs.addTab(self._folder_placeholder, tr("workspace.tab_folder")) - self._folder = FolderTab(self.ctx, cowork=self._cowork) - self._folder.status_message.connect(self.status_message) - self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder")) - - if self._structure is not None: - self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag")) + self._graph_placeholder = QWidget() + self._graphrag_tab_idx = self.tabs.addTab( + self._structure if self._structure is not None else self._graph_placeholder, + tr("workspace.tab_graphrag")) self.tabs.currentChanged.connect(self._on_tab_changed) @@ -420,10 +419,50 @@ class WorkspaceTab(ProjectEditingMixin, QWidget): Dựng lười như vậy chính là thứ giữ cho RAM lúc khởi động ở mức thấp. """ + if idx in (self._co4e_tab_idx, self._folder_tab_idx, self._graphrag_tab_idx): + self._ensure_heavy_tab(idx) if idx == self._graphrag_tab_idx and self._structure is not None: self._structure.auto_scan_and_fit() self._apply_pane_visibility() + def _ensure_heavy_tab(self, idx: int): + """Build a heavy Workspace child once, replacing its placeholder.""" + if idx == self._co4e_tab_idx and self._co4e is None: + from .co4e_tab import Co4ETab + real = Co4ETab(self.ctx) + self._co4e = real + self.tabs.removeTab(idx) + self.tabs.insertTab(idx, real, tr("workspace.tab_co4e")) + self.tabs.setCurrentIndex(idx) + self.tabs.setTabToolTip(idx, tr("workspace.tab_co4e_tooltip")) + self._bind_project(self._current_id) + return real + if idx == self._folder_tab_idx and self._folder is None: + from ..presentation.folder.folder_tab import FolderTab + real = FolderTab(self.ctx, cowork=self._cowork) + real.status_message.connect(self.status_message) + self._folder = real + self.tabs.removeTab(idx) + self.tabs.insertTab(idx, real, tr("workspace.tab_folder")) + self.tabs.setCurrentIndex(idx) + self._bind_project(self._current_id) + return real + if idx == self._graphrag_tab_idx and self._structure is None: + from ..presentation.graph.structure_graph_view import StructureGraphView + real = StructureGraphView(self.ctx) + real.status_message.connect(self.status_message) + if self._cowork is not None: + self._cowork.output_changed.connect(real.schedule_rescan) + self._structure = real + self.tabs.removeTab(idx) + self.tabs.insertTab(idx, real, tr("workspace.tab_graphrag")) + self.tabs.setCurrentIndex(idx) + self._bind_project(self._current_id) + return real + return {self._co4e_tab_idx: self._co4e, + self._folder_tab_idx: self._folder, + self._graphrag_tab_idx: self._structure}.get(idx) + def _apply_pane_visibility(self) -> None: """Which side panes accompany each sub-tab: