Compare commits

...
9 Commits
21 changed files with 247 additions and 60 deletions
+9
View File
@@ -527,6 +527,15 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)} preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview}) "preview": preview})
if ctx.block_network:
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
# R05-T04: MCP/connector tools used to run with NO permission # R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy, # check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below. # same gate object as the built-in tools below.
+6 -1
View File
@@ -327,7 +327,12 @@ def run_code(
else: else:
emit({"type": "tool_start", "id": tc_id, "name": name}) emit({"type": "tool_start", "id": tc_id, "name": name})
if is_extra and extra_executor is not None: if is_extra and extra_executor is not None:
result = extra_executor(name, args) if ctx.block_network:
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
else:
result = extra_executor(name, args)
else: else:
def on_output(line: str, _id=tc_id, _name=name) -> None: def on_output(line: str, _id=tc_id, _name=name) -> None:
emit({"type": "tool_output", "id": _id, "name": _name, "delta": line}) emit({"type": "tool_output", "id": _id, "name": _name, "delta": line})
+19 -8
View File
@@ -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 capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
before that cap, so a caller can report how many were skipped.""" before that cap, so a caller can report how many were skipped."""
exts = exts or INPUT_EXTS 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: try:
matched = sorted( for f in folder.rglob("*"):
f for f in folder.rglob("*") if not f.is_file():
if f.is_file() continue
and not any(part.startswith(".") for part in f.relative_to(folder).parts) try:
and f.suffix.lower() in exts 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: except OSError:
return [], 0 return [], 0
files = matched if max_files <= 0 else matched[:max_files] files.sort(key=lambda p: str(p).lower())
return files, len(matched) return files, total
def find_soffice() -> str | None: def find_soffice() -> str | None:
+30 -1
View File
@@ -16,6 +16,17 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List 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: 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.""" """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. # R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload) write_json(path, payload)
_invalidate_history_cache(directory)
return path return path
@@ -85,6 +97,7 @@ def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua.""" """Xoá file hội thoại; không có thì bỏ qua."""
try: try:
Path(path).unlink() Path(path).unlink()
_invalidate_history_cache(Path(path).parent)
except OSError: except OSError:
pass pass
@@ -96,6 +109,7 @@ def rename_conversation(path, new_title: str) -> None:
data = load_conversation(path) data = load_conversation(path)
data["title"] = new_title data["title"] = new_title
write_json(Path(path), data) write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def set_pinned(path, pinned: bool) -> None: def set_pinned(path, pinned: bool) -> None:
@@ -105,6 +119,7 @@ def set_pinned(path, pinned: bool) -> None:
data = load_conversation(path) data = load_conversation(path)
data["pinned"] = bool(pinned) data["pinned"] = bool(pinned)
write_json(Path(path), data) write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def load_conversation(path: Path) -> Dict[str, Any]: 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(): if not directory or not directory.exists():
return [] return []
q = (query or "").strip().lower() 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]] = [] 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: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
except (OSError, json.JSONDecodeError): except (OSError, json.JSONDecodeError):
@@ -221,4 +244,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
}) })
# pinned first, then most recent # pinned first, then most recent
items.sort(key=lambda d: (not d["pinned"], -d["mtime"])) 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 return items
+23
View File
@@ -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.
+31
View File
@@ -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 "")
+17 -4
View File
@@ -115,10 +115,23 @@ class ChatAgentsMixin:
if err: if err:
self.status_message.emit(tr("chatpanel.agent_list_error", err=err)) self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
w = AgentWorker(job) # Model discovery can involve a provider/network request. Constructing
w.finished_ok.connect(done) # the chat panel during startup must not wait for it; schedule it after
self._agent_worker = w # the first event-loop turn so the initial shell can paint immediately.
w.start() 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: def _populate_agents(self, models, keep: str) -> None:
"""Đổ danh sách vào bộ chọn Agent. """Đổ danh sách vào bộ chọn Agent.
+11 -2
View File
@@ -51,6 +51,8 @@ class MessageBubble(QFrame):
super().__init__() super().__init__()
self.role = role self.role = role
self._text = "" self._text = ""
self._stream_pending = False
self._render_count = 0
self._collapsible = collapsible self._collapsible = collapsible
self._title = title self._title = title
self._head = None self._head = None
@@ -164,12 +166,20 @@ class MessageBubble(QFrame):
def append_delta(self, delta: str) -> None: 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.""" """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._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: 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.""" """Đặ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._text = text
self.body.setMarkdown(text) self.body.setMarkdown(text)
self._render_count += 1
self._autosize() self._autosize()
if self._collapsible: if self._collapsible:
self._update_head() self._update_head()
@@ -393,4 +403,3 @@ class ChatView(QScrollArea):
ChatHistoryWidget = ChatView ChatHistoryWidget = ChatView
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"] __all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
+1 -1
View File
@@ -237,7 +237,7 @@ class GraphRenderer(QWidget):
# ---- prewarm / scan lifecycle -------------------------------------------------- # # ---- prewarm / scan lifecycle -------------------------------------------------- #
def prewarm(self) -> None: def prewarm(self) -> None:
"""Pay for the graph view before it is clicked on, not during.""" """Pay for the graph view before it is clicked on, not during."""
if not HAS_WEB_ENGINE or self.web is not None: if self.web is not None:
return return
self._ensure_web() self._ensure_web()
if self._graph is None and self.path_edit.text().strip(): if self._graph is None and self.path_edit.text().strip():
+8 -6
View File
@@ -19,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid
from ...core import audit_log from ...core import audit_log
from ...i18n import on_language_changed, tr from ...i18n import on_language_changed, tr
from ...state import AppContext from ...state import AppContext
from ...performance import span
from .tabs.action_logs_tab import ActionLogsTab from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab from .tabs.mcp_tab import McpTab
@@ -282,12 +283,13 @@ class MonitoringTab(QWidget):
""" """
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS) start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
shared_dir = self.ctx.config.shared_dir shared_dir = self.ctx.config.shared_dir
if shared_dir: with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS):
from ...core import telemetry_shared if shared_dir:
shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start) from ...core import telemetry_shared
if shared_events: shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
return shared_events if shared_events:
return audit_log.load_events(start=start) return shared_events
return audit_log.load_events(start=start)
def _apply_events_to_event_tabs(self, events: List[dict]) -> None: def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one """Filters the ALREADY-LOADED event list (see ``_load_events`` — one
@@ -216,7 +216,8 @@ class ToolsAdminTab(QWidget):
result. Respects the fetch_url toggle: when web access is OFF the agent result. Respects the fetch_url toggle: when web access is OFF the agent
cannot reach the internet, so the test reports that instead of probing.""" cannot reach the internet, so the test reports that instead of probing."""
disabled = ("fetch_url" in self.ctx.config.tools_disabled disabled = ("fetch_url" in self.ctx.config.tools_disabled
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))) or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
or bool(self.ctx.config.agent_security.get("block_network", False)))
if disabled: if disabled:
self.test_internet_status.setText(tr("tools_admin.internet_disabled")) self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
self.test_internet_status.setStyleSheet("color: #c00;") self.test_internet_status.setStyleSheet("color: #c00;")
@@ -27,7 +27,14 @@ def _frozen_onefile() -> bool:
return True return True
# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/
# helper-process failures leave the stacked view blank). The native Qt graph is
# already available and avoids that failure path entirely.
HAS_WEB_ENGINE = False
try: # WebEngine + WebChannel are optional PySide6 add-ons try: # WebEngine + WebChannel are optional PySide6 add-ons
if sys.platform == "darwin":
raise ImportError("use native graph renderer on macOS")
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
from PySide6.QtWebChannel import QWebChannel # noqa: F401 from PySide6.QtWebChannel import QWebChannel # noqa: F401
HAS_WEB_ENGINE = not _frozen_onefile() HAS_WEB_ENGINE = not _frozen_onefile()
+2 -5
View File
@@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH
from .tray_manager import TrayManager from .tray_manager import TrayManager
from ...state import AppContext from ...state import AppContext
from ...core.task_scheduler import TaskScheduler from ...core.task_scheduler import TaskScheduler
from ...ui.cowork_tab import CoworkTab
from ...ui.sidebar import HistorySidebar from ...ui.sidebar import HistorySidebar
from ..graph.structure_graph_view import StructureGraphView
from ...ui.workspace_tab import WorkspaceTab from ...ui.workspace_tab import WorkspaceTab
@@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
# Workspace screen (per selected project). GraphRAG's heavy # Workspace screen (per selected project). GraphRAG's heavy
# QtWebEngine is still built lazily on first display # QtWebEngine is still built lazily on first display
# (StructureGraphView._ensure_web). # (StructureGraphView._ensure_web).
from ...ui.cowork_tab import CoworkTab
self.cowork = CoworkTab(ctx) self.cowork = CoworkTab(ctx)
self.structure = StructureGraphView(ctx) self.structure = None
self.structure.status_message.connect(self.statusBar().showMessage)
self.cowork.output_changed.connect(self.structure.schedule_rescan)
self.cowork.status_message.connect(self.statusBar().showMessage) self.cowork.status_message.connect(self.statusBar().showMessage)
# Refresh History (list + running markers + current highlight) whenever a # Refresh History (list + running markers + current highlight) whenever a
# conversation is created/updated or a turn finishes. # conversation is created/updated or a turn finishes.
+3 -2
View File
@@ -142,10 +142,11 @@ class PageRegistryMixin:
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
không theo thứ vừa được bấm. không theo thứ vừa được bấm.
""" """
was_page = self.pages.currentIndex()
self._ensure_page(page) # build lazy page on first visit self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page) self.pages.setCurrentIndex(page)
if page == self._ROW_WORKSPACE: if page == self._ROW_WORKSPACE and was_page != page:
self.workspace.refresh() # re-list projects + threads on entry self.workspace.refresh() # refresh only when entering Workspace
widget = self._page_widgets[page] widget = self._page_widgets[page]
if sub is not None and hasattr(widget, "select_subtab"): if sub is not None and hasattr(widget, "select_subtab"):
# Enforce the project gate here rather than at each entry point. A # Enforce the project gate here rather than at each entry point. A
+2 -3
View File
@@ -19,9 +19,8 @@ _NAV_ROW_GAP = 6
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG # Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một # hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
# dòng menu — nên margin không mua được một pixel khoảng cách nào. # dòng menu — nên margin không mua được một pixel khoảng cách nào.
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không # Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
# rộng đến mức trông như hai khu tách rời. _NAV_SETTINGS_GAP = 0
_NAV_SETTINGS_GAP = 4
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du # 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh. # rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232 _NAV_MIN_WIDTH = 232
+10 -2
View File
@@ -42,7 +42,14 @@ class SessionEventsMixin:
self.sidebar.refresh() self.sidebar.refresh()
self._refresh_rail_recents() # the rail shortcut follows the panel 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: def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
"""Desktop notification for a finished scheduled task (toast always, """Desktop notification for a finished scheduled task (toast always,
tray balloon when the window isn't focused), then refresh History — 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.""" """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.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed 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
+4 -4
View File
@@ -59,11 +59,11 @@ class TopBarMixin:
# that number. Adding it again here made the row taller than the button # that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text # (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát. # 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0) srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP) srow.setSpacing(_NAV_ROW_GAP + 4)
self._nav_settings_icon = QLabel() self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(16, 16) self._nav_settings_icon.setFixedSize(22, 16)
self._nav_settings_text = QLabel(tr("app.settings")) self._nav_settings_text = QLabel(tr("app.settings"))
srow.addWidget(self._nav_settings_icon) srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text) srow.addWidget(self._nav_settings_text)
+1 -1
View File
@@ -52,7 +52,7 @@ class ProjectRow(QWidget):
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4) lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(0) lay.setSpacing(3)
self.title_label = QLabel(name) self.title_label = QLabel(name)
self.counts_label = QLabel() self.counts_label = QLabel()
self.counts_label.setObjectName("hint") self.counts_label.setObjectName("hint")
+4
View File
@@ -233,6 +233,10 @@ class AppContext:
connections across calls/turns (spawning a subprocess per turn would connections across calls/turns (spawning a subprocess per turn would
be slow and wasteful). A server/connector that fails to connect is be slow and wasteful). A server/connector that fails to connect is
skipped, not a hard failure for the turn.""" skipped, not a hard failure for the turn."""
# Sandbox Security Layer blocks agent-owned network connectors before
# they can spawn a server or issue a REST request.
if self.config.agent_security.get("block_network", False):
return [], None
# Master switch (Monitoring → Tools → Connector): when the admin turns # Master switch (Monitoring → Tools → Connector): when the admin turns
# "Connect to external" off, the agent connects to NO external # "Connect to external" off, the agent connects to NO external
# connectors/MCP at all — no subprocesses spawned, no REST calls. # connectors/MCP at all — no subprocesses spawned, no REST calls.
+2 -4
View File
@@ -78,8 +78,7 @@ _PATHS = {
"briefcase": '<rect x="2" y="7" width="20" height="14" rx="2"/>' "briefcase": '<rect x="2" y="7" width="20" height="14" rx="2"/>'
'<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>', '<path d="M16 21V5a2 2 0 0 0-2-2h-4a2 2 0 0 0-2 2v16"/>',
"award": '<circle cx="12" cy="8" r="7"/><polyline points="8.2 13.9 7 22 12 19 17 22 15.8 13.9"/>', "award": '<circle cx="12" cy="8" r="7"/><polyline points="8.2 13.9 7 22 12 19 17 22 15.8 13.9"/>',
"gear": '<circle cx="12" cy="12" r="3"/>' "gear": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>',
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>',
"globe": '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>' "globe": '<circle cx="12" cy="12" r="10"/><line x1="2" y1="12" x2="22" y2="12"/>'
'<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>', '<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z"/>',
"logout": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>' "logout": '<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/>'
@@ -199,8 +198,7 @@ _PATHS = {
'<line x1="7" y1="15" x2="17" y2="15"/>', # = beaker '<line x1="7" y1="15" x2="17" y2="15"/>', # = beaker
"sparkle": '<path d="M12 3l1.8 4.8L18.5 9.5 13.8 11.2 12 16l-1.8-4.8L5.5 9.5l4.7-1.7z"/>' "sparkle": '<path d="M12 3l1.8 4.8L18.5 9.5 13.8 11.2 12 16l-1.8-4.8L5.5 9.5l4.7-1.7z"/>'
'<path d="M19 15l.7 1.9L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.6z"/>', # = sparkles '<path d="M19 15l.7 1.9L21.5 17.5l-1.8.7L19 20l-.7-1.8L16.5 17.5l1.8-.6z"/>', # = sparkles
"settings": '<circle cx="12" cy="12" r="3"/>' "settings": '<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0L6.2 6.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.09a2 2 0 0 1 1 1.74v.5a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.38a2 2 0 0 0-.73-2.73l-.15-.09a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z"/><circle cx="12" cy="12" r="3"/>', # = gear
'<path d="M12 1v4M12 19v4M4.2 4.2l2.8 2.8M17 17l2.8 2.8M1 12h4M19 12h4M4.2 19.8L7 17M17 7l2.8-2.8"/>', # = gear
} }
+55 -15
View File
@@ -202,27 +202,26 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
cpl.addWidget(self._cowork) cpl.addWidget(self._cowork)
self._cowork_tab_idx = self.tabs.addTab(cowork_page, tr("workspace.tab_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 # Co4E and Folder are intentionally placeholders at startup. Their
# runner + chat). Always available (not project-gated): its workflows # widget trees pull in a large amount of Qt/UI code, but neither is on
# live globally under ~/.cowork_local/co4e, not inside one project. # the initial Project surface. The real page is created exactly once
# Placed BEFORE GraphRAG in the tab order (user request). # when its tab is first selected (see _ensure_heavy_tab).
from .co4e_tab import Co4ETab self._co4e = None
self._folder = None
self._co4e = Co4ETab(self.ctx) self._co4e_placeholder = QWidget()
self._co4e_tab_idx = self.tabs.addTab(self._co4e, tr("workspace.tab_co4e")) 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")) self.tabs.setTabToolTip(self._co4e_tab_idx, tr("workspace.tab_co4e_tooltip"))
# Folder — a two-pane file explorer (tree + view/edit) placed right below # Folder — a two-pane file explorer (tree + view/edit) placed right below
# Co4E. Always available (not project-gated); its root follows the # Co4E. Always available (not project-gated); its root follows the
# selected project's workspace folder when one is chosen. # 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._graph_placeholder = QWidget()
self._folder.status_message.connect(self.status_message) self._graphrag_tab_idx = self.tabs.addTab(
self._folder_tab_idx = self.tabs.addTab(self._folder, tr("workspace.tab_folder")) self._structure if self._structure is not None else self._graph_placeholder,
tr("workspace.tab_graphrag"))
if self._structure is not None:
self._graphrag_tab_idx = self.tabs.addTab(self._structure, tr("workspace.tab_graphrag"))
self.tabs.currentChanged.connect(self._on_tab_changed) self.tabs.currentChanged.connect(self._on_tab_changed)
@@ -307,6 +306,7 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
cloud_row.addWidget(self._cloud_sync_btn) cloud_row.addWidget(self._cloud_sync_btn)
cloud_row.addStretch(1) cloud_row.addStretch(1)
rl.addLayout(cloud_row) rl.addLayout(cloud_row)
self._cloud_pick_btn.hide()
self._cloud_badge_lbl = QLabel() self._cloud_badge_lbl = QLabel()
self._cloud_badge_lbl.setWordWrap(True) self._cloud_badge_lbl.setWordWrap(True)
self._cloud_badge_lbl.hide() self._cloud_badge_lbl.hide()
@@ -420,10 +420,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. 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: if idx == self._graphrag_tab_idx and self._structure is not None:
self._structure.auto_scan_and_fit() self._structure.auto_scan_and_fit()
self._apply_pane_visibility() 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: def _apply_pane_visibility(self) -> None:
"""Which side panes accompany each sub-tab: """Which side panes accompany each sub-tab: