Compare commits
11
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
2759ed94ba | ||
|
|
8548c1e923 | ||
|
|
caf3b74931 | ||
|
|
c00b83cd1a | ||
|
|
a04f8a928d | ||
|
|
cc8d5c8c0a | ||
|
|
2e3e719259 | ||
|
|
c699beb6fd | ||
|
|
7607f44030 | ||
|
|
cbae2604db | ||
|
|
b71a622227 |
Binary file not shown.
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -234,6 +234,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
|
|||||||
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
|
||||||
"monitoring.page_size_label": {
|
"monitoring.page_size_label": {
|
||||||
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
|
||||||
|
"monitoring.page_indicator": {
|
||||||
|
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
|
||||||
|
"monitoring.page_prev": {
|
||||||
|
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
|
||||||
|
"monitoring.page_next": {
|
||||||
|
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
|
||||||
"monitoring.pricing_title": {
|
"monitoring.pricing_title": {
|
||||||
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
|
||||||
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
"vi": "Bảng giá model (USD / 1 triệu token)"},
|
||||||
|
|||||||
@@ -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 "")
|
||||||
@@ -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.
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|
||||||
|
|||||||
@@ -10,13 +10,13 @@ from typing import List, Optional
|
|||||||
from PySide6.QtCore import QSize, Qt
|
from PySide6.QtCore import QSize, Qt
|
||||||
from PySide6.QtWidgets import QMenu
|
from PySide6.QtWidgets import QMenu
|
||||||
from ...core import co4e
|
from ...core import co4e
|
||||||
from ...core.co4e import STEP_DONE, STEP_RUNNING
|
from ...core.co4e import STEP_RUNNING
|
||||||
from ...i18n import tr
|
from ...i18n import tr
|
||||||
from ...ui.dialog_buttons import ask_text
|
from ...ui.dialog_buttons import ask_text
|
||||||
from ...ui.icons import icon
|
from ...ui.icons import icon
|
||||||
from ...presentation.co4e.co4e_chat_view import _skill_names
|
from ...presentation.co4e.co4e_chat_view import _skill_names
|
||||||
|
|
||||||
_LOCKED_NODE_STATUSES = (STEP_RUNNING, STEP_DONE)
|
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
|
||||||
|
|
||||||
|
|
||||||
class Co4EWorkflowCrudMixin:
|
class Co4EWorkflowCrudMixin:
|
||||||
@@ -169,8 +169,8 @@ class Co4EWorkflowCrudMixin:
|
|||||||
def _on_node_selected(self, node_id: str) -> None:
|
def _on_node_selected(self, node_id: str) -> None:
|
||||||
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
|
||||||
|
|
||||||
Bước đang chạy hoặc đã chạy xong thì khoá ô nhập liệu ngay khi nạp —
|
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
|
||||||
tránh sửa nhầm cấu hình của lần chạy đang xem kết quả.
|
vẫn sửa lại được bình thường.
|
||||||
"""
|
"""
|
||||||
for n in self.canvas.nodes():
|
for n in self.canvas.nodes():
|
||||||
if n.id == node_id:
|
if n.id == node_id:
|
||||||
|
|||||||
@@ -294,11 +294,11 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
|
|||||||
def set_locked(self, locked: bool) -> None:
|
def set_locked(self, locked: bool) -> None:
|
||||||
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
|
||||||
|
|
||||||
Bước đang chạy hoặc đã chạy xong thì khoá lại — tránh sửa nhầm cấu
|
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
|
||||||
hình trong lúc đang xem kết quả của chính lần chạy đó (sửa xong
|
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
|
||||||
không rõ là áp dụng cho lần chạy đã xong hay lần chạy tiếp theo).
|
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
|
||||||
Nút Chạy/Chạy từ đây/Xoá bước vẫn hoạt động bình thường khi khoá —
|
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
|
||||||
chỉ ô nhập liệu bị khoá, không phải cả panel.
|
phải cả panel.
|
||||||
"""
|
"""
|
||||||
self._locked = locked
|
self._locked = locked
|
||||||
editable = not locked
|
editable = not locked
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -39,8 +40,9 @@ _UNBOUNDED_PAGE_SIZE = 100_000
|
|||||||
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
|
||||||
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
# gets re-read and re-parsed on EVERY tick, which is what actually made
|
||||||
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
|
||||||
# rendering (EventTable already caps display at 300 rows — see
|
# rendering (EventTable paginates client-side, 5-100 rows/page — see
|
||||||
# shared/event_table.py::_MAX_ROWS), it was this repeated full-history read.
|
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
|
||||||
|
# read.
|
||||||
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
# 30 days is a live-monitoring window, not a hard retention limit — nothing
|
||||||
# is deleted, older days are simply not re-read on every 3s tick.
|
# is deleted, older days are simply not re-read on every 3s tick.
|
||||||
_LOG_WINDOW_DAYS = 30
|
_LOG_WINDOW_DAYS = 30
|
||||||
@@ -281,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
|
||||||
|
|||||||
@@ -6,7 +6,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import List, Optional
|
from typing import List, Optional
|
||||||
|
|
||||||
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt
|
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
|
||||||
from PySide6.QtGui import QBrush, QColor
|
from PySide6.QtGui import QBrush, QColor
|
||||||
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
|
||||||
|
|
||||||
@@ -17,8 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
|
|||||||
from .badges import action_label
|
from .badges import action_label
|
||||||
from .formatters import agent_avatar_icon, fmt_event_time
|
from .formatters import agent_avatar_icon, fmt_event_time
|
||||||
|
|
||||||
_MAX_ROWS = 300
|
_DEFAULT_PAGE_SIZE = 20
|
||||||
PAGE_SIZE_OPTIONS = (50, 100, 300, 500, 1000)
|
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
|
||||||
|
|
||||||
|
|
||||||
class _TimeItem(QTableWidgetItem):
|
class _TimeItem(QTableWidgetItem):
|
||||||
@@ -63,6 +63,12 @@ class EventTable(QTableWidget):
|
|||||||
"secret_in_output": "warning",
|
"secret_in_output": "warning",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
# Emitted whenever the rendered page changes (new data, page-size change,
|
||||||
|
# or prev/next navigation) — args are (current_page, page_count), both
|
||||||
|
# 1-based-friendly in that current_page is 0-indexed but page_count is a
|
||||||
|
# plain count. filter_scaffold.py's pager label/buttons listen to this.
|
||||||
|
page_changed = Signal(int, int)
|
||||||
|
|
||||||
def __init__(self, show_result: bool = True):
|
def __init__(self, show_result: bool = True):
|
||||||
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
# Security Events drops the result column entirely (see _ACTION_TINTS).
|
||||||
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
"""Bảng sự kiện dùng chung của các tab Giám sát.
|
||||||
@@ -71,8 +77,10 @@ class EventTable(QTableWidget):
|
|||||||
là thất bại nên cột ấy chỉ tốn chỗ.
|
là thất bại nên cột ấy chỉ tốn chỗ.
|
||||||
"""
|
"""
|
||||||
self._show_result = show_result
|
self._show_result = show_result
|
||||||
self._page_size = _MAX_ROWS
|
self._page_size = _DEFAULT_PAGE_SIZE
|
||||||
|
self._current_page = 0
|
||||||
self._last_events: List[dict] = []
|
self._last_events: List[dict] = []
|
||||||
|
self._sorted_events: List[dict] = []
|
||||||
super().__init__(0, 7 if show_result else 6)
|
super().__init__(0, 7 if show_result else 6)
|
||||||
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
self.setEditTriggers(QTableWidget.NoEditTriggers)
|
||||||
self.setSelectionBehavior(QTableWidget.SelectRows)
|
self.setSelectionBehavior(QTableWidget.SelectRows)
|
||||||
@@ -105,21 +113,56 @@ class EventTable(QTableWidget):
|
|||||||
"""Số dòng đang hiển thị mỗi trang."""
|
"""Số dòng đang hiển thị mỗi trang."""
|
||||||
return self._page_size
|
return self._page_size
|
||||||
|
|
||||||
|
def page_count(self) -> int:
|
||||||
|
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
|
||||||
|
if not self._sorted_events:
|
||||||
|
return 1
|
||||||
|
return -(-len(self._sorted_events) // self._page_size) # ceil div
|
||||||
|
|
||||||
|
def current_page(self) -> int:
|
||||||
|
"""Trang đang hiển thị, đánh số từ 0."""
|
||||||
|
return self._current_page
|
||||||
|
|
||||||
|
def go_to_page(self, page: int) -> None:
|
||||||
|
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
|
||||||
|
self._current_page = page
|
||||||
|
self._render_current_page()
|
||||||
|
|
||||||
|
def next_page(self) -> None:
|
||||||
|
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
|
||||||
|
self.go_to_page(self._current_page + 1)
|
||||||
|
|
||||||
|
def prev_page(self) -> None:
|
||||||
|
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
|
||||||
|
self.go_to_page(self._current_page - 1)
|
||||||
|
|
||||||
def set_page_size(self, n: int) -> None:
|
def set_page_size(self, n: int) -> None:
|
||||||
"""Đổi số dòng hiển thị mỗi trang rồi vẽ lại với dữ liệu đã có sẵn
|
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
|
||||||
(không cần refresh lại từ nguồn — set_events() đã lưu lại lần đổ gần nhất)."""
|
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
|
||||||
self._page_size = n
|
self._page_size = n
|
||||||
self.set_events(self._last_events)
|
self._current_page = 0
|
||||||
|
self._render_current_page()
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``self._page_size``
|
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
|
||||||
(đổi được qua ``set_page_size`` — control "Số dòng/trang" ở filter_scaffold.py).
|
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
|
||||||
|
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
|
||||||
|
trước (DF-006)."""
|
||||||
|
self._last_events = events
|
||||||
|
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
|
||||||
|
self._current_page = 0
|
||||||
|
self._render_current_page()
|
||||||
|
|
||||||
|
def _render_current_page(self) -> None:
|
||||||
|
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
|
||||||
|
từ ``self._sorted_events`` đã sắp sẵn.
|
||||||
|
|
||||||
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
|
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
|
||||||
nạp chậm đi theo bậc hai.
|
nạp chậm đi theo bậc hai.
|
||||||
"""
|
"""
|
||||||
self._last_events = events
|
self._current_page = max(0, min(self._current_page, self.page_count() - 1))
|
||||||
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:self._page_size]
|
start = self._current_page * self._page_size
|
||||||
|
events = self._sorted_events[start:start + self._page_size]
|
||||||
self.setSortingEnabled(False)
|
self.setSortingEnabled(False)
|
||||||
self.setRowCount(len(events))
|
self.setRowCount(len(events))
|
||||||
for row, ev in enumerate(events):
|
for row, ev in enumerate(events):
|
||||||
@@ -164,6 +207,7 @@ class EventTable(QTableWidget):
|
|||||||
self.setItem(row, col, item)
|
self.setItem(row, col, item)
|
||||||
self.setSortingEnabled(True)
|
self.setSortingEnabled(True)
|
||||||
self.apply_filter(getattr(self, "_filter_needle", ""))
|
self.apply_filter(getattr(self, "_filter_needle", ""))
|
||||||
|
self.page_changed.emit(self._current_page, self.page_count())
|
||||||
|
|
||||||
def apply_filter(self, needle: str) -> None:
|
def apply_filter(self, needle: str) -> None:
|
||||||
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
|
||||||
|
|||||||
@@ -95,20 +95,52 @@ def build_filter_scaffold(
|
|||||||
if with_page_size and isinstance(table, EventTable):
|
if with_page_size and isinstance(table, EventTable):
|
||||||
# DF-006: the item-per-page count was never surfaced anywhere in
|
# DF-006: the item-per-page count was never surfaced anywhere in
|
||||||
# the UI (design called for it) — EventTable already trims to a
|
# the UI (design called for it) — EventTable already trims to a
|
||||||
# page size internally (default 300), this just makes that
|
# page size internally, this just makes that number visible AND
|
||||||
# number visible AND user-choosable instead of a fixed constant.
|
# user-choosable instead of a fixed constant.
|
||||||
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
|
||||||
page_size_combo = QComboBox()
|
page_size_combo = QComboBox()
|
||||||
for n in PAGE_SIZE_OPTIONS:
|
for n in PAGE_SIZE_OPTIONS:
|
||||||
page_size_combo.addItem(str(n), n)
|
page_size_combo.addItem(str(n), n)
|
||||||
current = table.page_size()
|
current = table.page_size()
|
||||||
page_size_combo.setCurrentIndex(
|
page_size_combo.setCurrentIndex(
|
||||||
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 2)
|
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
|
||||||
page_size_combo.currentIndexChanged.connect(
|
page_size_combo.currentIndexChanged.connect(
|
||||||
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
lambda i: table.set_page_size(page_size_combo.itemData(i)))
|
||||||
row.addWidget(page_size_lbl)
|
row.addWidget(page_size_lbl)
|
||||||
row.addWidget(page_size_combo)
|
row.addWidget(page_size_combo)
|
||||||
parts.update(page_size_label=page_size_lbl, page_size_combo=page_size_combo)
|
|
||||||
|
# DF-006 follow-up: trimming to a page size alone silently dropped
|
||||||
|
# every row past it with no way back to see them — prev/next
|
||||||
|
# buttons plus a "trang X/Y" indicator make the rest reachable.
|
||||||
|
page_prev_btn = QPushButton()
|
||||||
|
page_prev_btn.setIcon(icon("chevron-left"))
|
||||||
|
page_prev_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
bind_tip(page_prev_btn, "monitoring.page_prev")
|
||||||
|
page_next_btn = QPushButton()
|
||||||
|
page_next_btn.setIcon(icon("chevron-right"))
|
||||||
|
page_next_btn.setCursor(Qt.PointingHandCursor)
|
||||||
|
bind_tip(page_next_btn, "monitoring.page_next")
|
||||||
|
page_indicator_lbl = QLabel()
|
||||||
|
|
||||||
|
def _refresh_pager(cur: int = None, total: int = None) -> None:
|
||||||
|
if cur is None or total is None:
|
||||||
|
cur, total = table.current_page(), table.page_count()
|
||||||
|
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
|
||||||
|
page_prev_btn.setEnabled(cur > 0)
|
||||||
|
page_next_btn.setEnabled(cur < total - 1)
|
||||||
|
|
||||||
|
page_prev_btn.clicked.connect(table.prev_page)
|
||||||
|
page_next_btn.clicked.connect(table.next_page)
|
||||||
|
table.page_changed.connect(_refresh_pager)
|
||||||
|
_refresh_pager()
|
||||||
|
|
||||||
|
row.addWidget(page_prev_btn)
|
||||||
|
row.addWidget(page_indicator_lbl)
|
||||||
|
row.addWidget(page_next_btn)
|
||||||
|
parts.update(
|
||||||
|
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
|
||||||
|
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
|
||||||
|
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
|
||||||
lay.addLayout(row)
|
lay.addLayout(row)
|
||||||
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class ActionLogsTab(QWidget):
|
|||||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||||
self.detail_panel = parts["detail_panel"]
|
self.detail_panel = parts["detail_panel"]
|
||||||
self.page_size_label = parts["page_size_label"]
|
self.page_size_label = parts["page_size_label"]
|
||||||
|
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng."""
|
"""Đổ danh sách sự kiện vào bảng."""
|
||||||
@@ -47,6 +48,7 @@ class ActionLogsTab(QWidget):
|
|||||||
self.title_lbl.setText(tr(self.title_key))
|
self.title_lbl.setText(tr(self.title_key))
|
||||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||||
|
self.page_pager_refresh()
|
||||||
|
|
||||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ class McpTab(QWidget):
|
|||||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||||
self.detail_panel = parts["detail_panel"]
|
self.detail_panel = parts["detail_panel"]
|
||||||
self.page_size_label = parts["page_size_label"]
|
self.page_size_label = parts["page_size_label"]
|
||||||
|
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng."""
|
"""Đổ danh sách sự kiện vào bảng."""
|
||||||
@@ -47,6 +48,7 @@ class McpTab(QWidget):
|
|||||||
self.title_lbl.setText(tr(self.title_key))
|
self.title_lbl.setText(tr(self.title_key))
|
||||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||||
|
self.page_pager_refresh()
|
||||||
|
|
||||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||||
|
|||||||
@@ -40,6 +40,7 @@ class SecurityEventsTab(QWidget):
|
|||||||
self.ai_filter_btn = parts["ai_filter_btn"]
|
self.ai_filter_btn = parts["ai_filter_btn"]
|
||||||
self.detail_panel = parts["detail_panel"]
|
self.detail_panel = parts["detail_panel"]
|
||||||
self.page_size_label = parts["page_size_label"]
|
self.page_size_label = parts["page_size_label"]
|
||||||
|
self.page_pager_refresh = parts["page_pager_refresh"]
|
||||||
|
|
||||||
def set_events(self, events: List[dict]) -> None:
|
def set_events(self, events: List[dict]) -> None:
|
||||||
"""Đổ danh sách sự kiện vào bảng."""
|
"""Đổ danh sách sự kiện vào bảng."""
|
||||||
@@ -53,6 +54,7 @@ class SecurityEventsTab(QWidget):
|
|||||||
self.title_lbl.setText(tr(self.title_key))
|
self.title_lbl.setText(tr(self.title_key))
|
||||||
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
self.title_refresh_btn.setText(tr("monitoring.refresh"))
|
||||||
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
self.page_size_label.setText(tr("monitoring.page_size_label"))
|
||||||
|
self.page_pager_refresh()
|
||||||
|
|
||||||
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
|
||||||
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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")
|
||||||
|
|||||||
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
|
|||||||
set "VENV=%APPHOME%\venv"
|
set "VENV=%APPHOME%\venv"
|
||||||
set "LAUNCHER=%APPHOME%\launcher"
|
set "LAUNCHER=%APPHOME%\launcher"
|
||||||
|
|
||||||
|
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
|
||||||
|
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
|
||||||
|
rem lai cua so truoc khi in, de thong bao van doc duoc.
|
||||||
|
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
||||||
|
if exist "%CONSOLE_VIS%" (
|
||||||
|
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
||||||
|
)
|
||||||
|
|
||||||
rem --------------------------------------------------------------------------
|
rem --------------------------------------------------------------------------
|
||||||
rem 1. Chon trinh thong dich
|
rem 1. Chon trinh thong dich
|
||||||
rem
|
rem
|
||||||
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
|
|||||||
)
|
)
|
||||||
|
|
||||||
if not defined RUNPY (
|
if not defined RUNPY (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
|
||||||
echo.
|
echo.
|
||||||
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
|
|||||||
if not exist "%VENV%\Scripts\python.exe" (
|
if not exist "%VENV%\Scripts\python.exe" (
|
||||||
!RUNPY! -c "import PySide6" >nul 2>&1
|
!RUNPY! -c "import PySide6" >nul 2>&1
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
|
||||||
echo.
|
echo.
|
||||||
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
|
|||||||
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
|
||||||
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
|
||||||
if errorlevel 1 (
|
if errorlevel 1 (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LOI] Khong tao duoc lien ket thu muc:
|
echo [LOI] Khong tao duoc lien ket thu muc:
|
||||||
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
echo "!PKGPATH!\cowork_local" -> "%REPO%"
|
||||||
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
|
|||||||
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
|
||||||
rem ("'cowork_local' is a package and cannot be directly executed").
|
rem ("'cowork_local' is a package and cannot be directly executed").
|
||||||
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
if not exist "!PKGPATH!\cowork_local\__main__.py" (
|
||||||
|
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
|
||||||
echo.
|
echo.
|
||||||
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
|
||||||
echo "!PKGPATH!"
|
echo "!PKGPATH!"
|
||||||
@@ -139,18 +151,12 @@ set "PYTHONIOENCODING=utf-8"
|
|||||||
cd /d "%REPO%"
|
cd /d "%REPO%"
|
||||||
|
|
||||||
rem --------------------------------------------------------------------------
|
rem --------------------------------------------------------------------------
|
||||||
rem 4. An cua so console trong luc chay
|
rem 4. Chay app
|
||||||
rem
|
rem
|
||||||
rem App la GUI (Qt), khong can console — nhung no chia se console cua chinh
|
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
|
||||||
rem cmd nay (khong tu mo cua so rieng), nen cua so den cua run.bat cu the
|
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
|
||||||
rem hien suot phien lam viec neu khong lam gi. An no ngay truoc khi chay, roi
|
rem thong bao loi ben duoi van doc duoc.
|
||||||
rem chi hien lai NEU app thoat loi, de thong bao loi ben duoi van doc duoc.
|
|
||||||
rem --------------------------------------------------------------------------
|
rem --------------------------------------------------------------------------
|
||||||
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
|
|
||||||
if exist "%CONSOLE_VIS%" (
|
|
||||||
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
|
|
||||||
)
|
|
||||||
|
|
||||||
!RUNPY! -m cowork_local %*
|
!RUNPY! -m cowork_local %*
|
||||||
set "RC=%ERRORLEVEL%"
|
set "RC=%ERRORLEVEL%"
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -0,0 +1,55 @@
|
|||||||
|
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
|
||||||
|
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
|
||||||
|
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
|
||||||
|
|
||||||
|
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
|
||||||
|
trở lại. Test này chốt cả nguồn sự thật (tuple
|
||||||
|
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
|
||||||
|
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
|
||||||
|
"""
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import os
|
||||||
|
|
||||||
|
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
|
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
|
||||||
|
|
||||||
|
from cowork_local.core.co4e import (
|
||||||
|
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
|
||||||
|
)
|
||||||
|
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
|
||||||
|
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture(scope="module")
|
||||||
|
def qapp():
|
||||||
|
app = QApplication.instance() or QApplication([])
|
||||||
|
yield app
|
||||||
|
|
||||||
|
|
||||||
|
def test_only_running_status_is_locked() -> None:
|
||||||
|
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
|
||||||
|
thực sự chạy (STEP_RUNNING) mới bị khoá."""
|
||||||
|
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
|
||||||
|
assert STEP_DONE not in _LOCKED_NODE_STATUSES
|
||||||
|
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
|
||||||
|
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
|
||||||
|
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
|
||||||
|
panel = StepConfigPanel()
|
||||||
|
panel.setEnabled(True) # panel starts disabled until a step is loaded
|
||||||
|
|
||||||
|
panel.set_locked(True)
|
||||||
|
assert not panel.label_edit.isEnabled()
|
||||||
|
assert not panel.instructions_edit.isEnabled()
|
||||||
|
assert not panel.model_combo.isEnabled()
|
||||||
|
|
||||||
|
panel.set_locked(False)
|
||||||
|
assert panel.label_edit.isEnabled()
|
||||||
|
assert panel.instructions_edit.isEnabled()
|
||||||
|
assert panel.model_combo.isEnabled()
|
||||||
@@ -1,9 +1,11 @@
|
|||||||
"""DF-006 — the "Số dòng/trang" (rows per page) control: EventTable's
|
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
|
||||||
page-size state (presentation/monitoring/shared/event_table.py) and its
|
pagination: EventTable's page-size/page-index state
|
||||||
QComboBox wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
|
||||||
No dedicated test existed for this before — the design called for a
|
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
|
||||||
user-visible/choosable item-per-page control, and this exercises it end to
|
|
||||||
end (combo selection -> EventTable actually re-trimming its rows)."""
|
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
|
||||||
|
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
|
||||||
|
it with no way back); this exercises the real paging end to end."""
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
import os
|
import os
|
||||||
@@ -32,19 +34,62 @@ def _events(n: int):
|
|||||||
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
|
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
|
||||||
|
|
||||||
|
|
||||||
def test_default_page_size_matches_old_max_rows(qapp) -> None:
|
def test_page_size_options_are_5_10_20_50_100() -> None:
|
||||||
table = EventTable()
|
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
|
||||||
assert table.page_size() == 300
|
|
||||||
table.set_events(_events(500))
|
|
||||||
assert table.rowCount() == 300
|
|
||||||
|
|
||||||
|
|
||||||
def test_set_page_size_retrims_without_reloading(qapp) -> None:
|
def test_default_page_size(qapp) -> None:
|
||||||
table = EventTable()
|
table = EventTable()
|
||||||
table.set_events(_events(500))
|
assert table.page_size() == 20
|
||||||
|
table.set_events(_events(45))
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
assert table.page_count() == 3
|
||||||
|
assert table.current_page() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_set_page_size_resets_to_first_page(qapp) -> None:
|
||||||
|
table = EventTable()
|
||||||
|
table.set_events(_events(45))
|
||||||
|
table.next_page()
|
||||||
|
assert table.current_page() == 1
|
||||||
table.set_page_size(50)
|
table.set_page_size(50)
|
||||||
assert table.page_size() == 50
|
assert table.page_size() == 50
|
||||||
assert table.rowCount() == 50
|
assert table.current_page() == 0
|
||||||
|
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
|
||||||
|
|
||||||
|
|
||||||
|
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
|
||||||
|
table = EventTable()
|
||||||
|
table.set_events(_events(45))
|
||||||
|
table.set_page_size(20)
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
|
||||||
|
table.next_page()
|
||||||
|
assert table.current_page() == 1
|
||||||
|
assert table.rowCount() == 20
|
||||||
|
|
||||||
|
table.next_page()
|
||||||
|
assert table.current_page() == 2
|
||||||
|
assert table.rowCount() == 5 # last page: remainder
|
||||||
|
|
||||||
|
table.next_page() # already on last page — stays put
|
||||||
|
assert table.current_page() == 2
|
||||||
|
|
||||||
|
table.prev_page()
|
||||||
|
assert table.current_page() == 1
|
||||||
|
table.prev_page()
|
||||||
|
table.prev_page() # already on first page — stays put
|
||||||
|
assert table.current_page() == 0
|
||||||
|
|
||||||
|
|
||||||
|
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
|
||||||
|
table = EventTable()
|
||||||
|
seen = []
|
||||||
|
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
|
||||||
|
table.set_events(_events(45))
|
||||||
|
table.set_page_size(20)
|
||||||
|
table.next_page()
|
||||||
|
assert seen[-1] == (1, 3)
|
||||||
|
|
||||||
|
|
||||||
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
||||||
@@ -52,19 +97,44 @@ def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
|
|||||||
table = EventTable()
|
table = EventTable()
|
||||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
|
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
|
||||||
assert "page_size_combo" not in parts
|
assert "page_size_combo" not in parts
|
||||||
|
assert "page_prev_btn" not in parts
|
||||||
|
|
||||||
|
|
||||||
def test_page_size_combo_changes_the_table(qapp) -> None:
|
def test_page_size_combo_changes_the_table(qapp) -> None:
|
||||||
page = QWidget()
|
page = QWidget()
|
||||||
table = EventTable()
|
table = EventTable()
|
||||||
table.set_events(_events(500))
|
table.set_events(_events(45))
|
||||||
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||||
combo = parts["page_size_combo"]
|
combo = parts["page_size_combo"]
|
||||||
assert combo.count() == len(PAGE_SIZE_OPTIONS)
|
assert combo.count() == len(PAGE_SIZE_OPTIONS)
|
||||||
assert combo.currentData() == 300 # matches EventTable's current page_size
|
assert combo.currentData() == 20 # matches EventTable's current page_size
|
||||||
|
|
||||||
idx = PAGE_SIZE_OPTIONS.index(50)
|
idx = PAGE_SIZE_OPTIONS.index(10)
|
||||||
combo.setCurrentIndex(idx)
|
combo.setCurrentIndex(idx)
|
||||||
|
|
||||||
assert table.page_size() == 50
|
assert table.page_size() == 10
|
||||||
assert table.rowCount() == 50
|
assert table.rowCount() == 10
|
||||||
|
|
||||||
|
|
||||||
|
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
|
||||||
|
page = QWidget()
|
||||||
|
table = EventTable()
|
||||||
|
table.set_events(_events(45))
|
||||||
|
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
|
||||||
|
table.set_page_size(20)
|
||||||
|
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
|
||||||
|
indicator = parts["page_indicator_label"]
|
||||||
|
|
||||||
|
assert not prev_btn.isEnabled()
|
||||||
|
assert next_btn.isEnabled()
|
||||||
|
assert indicator.text() == "Trang 1/3"
|
||||||
|
|
||||||
|
next_btn.click()
|
||||||
|
assert prev_btn.isEnabled()
|
||||||
|
assert next_btn.isEnabled()
|
||||||
|
assert indicator.text() == "Trang 2/3"
|
||||||
|
|
||||||
|
next_btn.click()
|
||||||
|
assert prev_btn.isEnabled()
|
||||||
|
assert not next_btn.isEnabled()
|
||||||
|
assert indicator.text() == "Trang 3/3"
|
||||||
|
|||||||
+2
-4
@@ -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
@@ -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:
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user