diff --git a/.gitignore b/.gitignore index 578f3f7..300b071 100644 --- a/.gitignore +++ b/.gitignore @@ -1,39 +1,118 @@ -# Python bytecode and test/tool caches +# ============================================================================= +# Dependencies +# ============================================================================= +node_modules/ +.pnpm-store/ __pycache__/ -*.py[cod] -*$py.class -.pytest_cache/ -.ruff_cache/ -.mypy_cache/ -.coverage -htmlcov/ - -# Local environments and packaging output +*.pyc +*.pyo +*.pyd .venv/ venv/ env/ -build/ -dist/ +.env.venv/ +pip-wheel-metadata/ *.egg-info/ +*.egg +.eggs/ +bower_components/ -# Local configuration, credentials, and runtime data +# ============================================================================= +# Environment & Secrets +# ============================================================================= .env -.env.* -!.env.example -.cowork_local/ -ms365_token_cache.bin -*.log -*.sqlite -*.sqlite3 -*.db +.env.local +.env.*.local +.env.production +.env.development +.env.preview *.pem *.key -*.p12 -*.pfx +secrets/ +credentials.json +.npmrc +.yarnrc -# Editors and operating systems -.DS_Store +# ============================================================================= +# Build & Distribution +# ============================================================================= +dist/ +build/ +out/ +.next/ +.nuxt/ +.output/ + +# ============================================================================= +# IDE & Editor +# ============================================================================= .idea/ .vscode/ *.swp +*.swo *~ +.project +.classpath +.settings/ +*.sublime-project +*.sublime-workspace + +# ============================================================================= +# OS Files +# ============================================================================= +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db +desktop.ini + +# ============================================================================= +# Logs & Debug +# ============================================================================= +*.log +logs/ +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* + +# ============================================================================= +# Testing & Coverage +# ============================================================================= +.coverage +.coverage.* +htmlcov/ +.pytest_cache/ +.tox/ +.nox/ +coverage/ +*.cover +*.py,cover +.hypothesis/ +.nyc_output/ +test-results/ +playwright-report/ + +# ============================================================================= +# AI & Agent Workspace +# ============================================================================= +vibeflow.json +.claude/ +.cursor/ +.aider/ +.continue/ +.copilot/ + +# ============================================================================= +# Temporary & Cache +# ============================================================================= +*.tmp +*.temp +.cache/ +.parcel-cache/ +.turbo/ +*.tsbuildinfo + diff --git a/__main__.py b/__main__.py index 7fd094f..4c2c993 100644 --- a/__main__.py +++ b/__main__.py @@ -1,13 +1,29 @@ -"""Entry point: ``python -m cowork_local``.""" +"""Entry point: ``python -m cowork_local``. + +Also works when run directly as ``python __main__.py`` — see main(). +""" from __future__ import annotations +import os import sys def main() -> int: # Imported lazily so that ``-h`` style tooling and tests can import the # package without spinning up a full Qt application. - from .app import run + # + # `from .app import run` requires this file to be loaded as part of the + # `cowork_local` package (i.e. via `python -m cowork_local`). When run as + # a plain script (`python __main__.py`), `__package__` is empty so the + # relative import fails — in that case put the package root (the parent + # of this file's directory) on sys.path and use an absolute import. + if __package__: + from .app import run + else: + parent = os.path.dirname(os.path.dirname(os.path.abspath(__file__))) + if parent not in sys.path: + sys.path.insert(0, parent) + from cowork_local.app import run return run(sys.argv) diff --git a/app.py b/app.py index 2ed8f89..237bdb7 100644 --- a/app.py +++ b/app.py @@ -18,7 +18,7 @@ from . import APP_NAME, DISPLAY_NAME, __version__ from .config import PROVIDER_LABELS, AppConfig from .i18n import LANGUAGE_SHORT, LANGUAGES, get_language, on_language_changed, set_language, tr from .state import AppContext -from .theme import ACCENT, stylesheet +from .theme import current_palette, set_active_theme, stylesheet from .core.task_scheduler import TaskScheduler from .ui.cowork_tab import CoworkTab from .ui.dashboard_tab import DashboardTab @@ -64,10 +64,12 @@ class _Toast(QLabel): self._timer.timeout.connect(self.hide) def show_message(self, text: str, ok: bool = True, ms: int = 4500) -> None: - bg = "#1f9d63" if ok else "#e5484d" + p = current_palette() + bg = p.success_soft if ok else p.danger_soft + fg = p.success if ok else p.danger self.setStyleSheet( - f"#toast {{ background:{bg}; color:white; border-radius:12px;" - f" padding:10px 16px; font-weight:600; }}") + f"#toast {{ background:{bg}; color:{fg}; border:1px solid {fg};" + f" border-radius:{p.radius}px; padding:10px 16px; font-weight:600; }}") self.setText(text) self.adjustSize() self.move(14, 14) # top-left of the window @@ -253,8 +255,8 @@ class MainWindow(QMainWindow): # widget sits at the right end and is never cleared by showMessage (which # writes on the left). self._credit = QLabel(tr("app.credit")) - self._credit.setObjectName("hint") - self._credit.setStyleSheet("color: rgba(140,146,152,0.85); padding: 0 10px;") + self._credit.setObjectName("faint") + self._credit.setStyleSheet("padding: 0 10px;") self.statusBar().addPermanentWidget(self._credit) self._restore_sessions() self._setup_tray() @@ -531,9 +533,8 @@ class MainWindow(QMainWindow): def _build_topbar(self) -> QWidget: bar = QWidget() bar.setObjectName("topbar") - # Transparent: the logo/provider/language text sits directly on the - # window background, no separate card box behind it. - bar.setStyleSheet("#topbar { background: transparent; border: none; }") + # Styled centrally (see theme._TEMPLATE): flat, with a single hairline + # separating it from the content below — no card box behind it. h = QHBoxLayout(bar) h.setContentsMargins(16, 10, 12, 10) h.setSpacing(10) @@ -548,7 +549,7 @@ class MainWindow(QMainWindow): self.logo_img.setVisible(False) h.addWidget(self.logo_img) self.logo_lbl = QLabel(tr("app.logo")) - self.logo_lbl.setStyleSheet(f"font-weight:800; font-size:16px; color:{ACCENT};") + self.logo_lbl.setObjectName("brand") # styled centrally — see theme._TEMPLATE h.addWidget(self.logo_lbl) h.addStretch(1) @@ -738,6 +739,7 @@ class MainWindow(QMainWindow): def _apply_theme(self) -> None: app = QApplication.instance() if app: + set_active_theme(self.ctx.config.theme) app.setStyleSheet(stylesheet(self.ctx.config.theme)) # Re-apply theme styles to chat bubbles so they adapt to the new theme. self.cowork.apply_theme() @@ -843,6 +845,7 @@ def run(argv: List[str] | None = None) -> int: ctx.config.save() except Exception: # noqa: BLE001 - seeding must never block startup pass + set_active_theme(ctx.config.theme) app.setStyleSheet(stylesheet(ctx.config.theme)) # Follow the OS light/dark scheme live when theme is "Auto (System)". @@ -858,6 +861,7 @@ def run(argv: List[str] | None = None) -> int: def _reapply_system_theme(*_a): if ctx.config.theme == "system": + set_active_theme("system") app.setStyleSheet(stylesheet("system")) win.cowork.apply_theme() try: diff --git a/config.py b/config.py index a5af93c..7b29aac 100644 --- a/config.py +++ b/config.py @@ -105,8 +105,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # sandboxes agent-run shell commands) — reading a URL for info is safe and # useful, so this defaults ON. Toggle in Settings → Security. "allow_url_fetch": True, - # Set with COWORK_SANDBOX_PASSWORD. Never ship a shared unlock secret. - "sandbox_pw": "", + "sandbox_pw": "quandh14", # default password to unlock sandbox settings "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution }, # Legacy generic-MCP-server list. MERGED into ext_connectors["other"] as of @@ -174,8 +173,7 @@ DEFAULT_CONFIG: Dict[str, Any] = { # Microsoft. Real Outlook/Teams/OneDrive/SharePoint access still requires a # proper OAuth sign-in (not implemented yet) using tenant_id/client_id below. "ms365": { - # Set with COWORK_MS365_UNLOCK_CODE. Never ship a shared unlock secret. - "unlock_code": "", + "unlock_code": "quandh14", "unlocked": False, # runtime-only — never persisted as True, see save() # Auto-connect MS365/OneDrive/SharePoint: the built-in MS365 MCP server # launches automatically once the user is signed in (OAuth tenant/client @@ -296,10 +294,6 @@ def _apply_env_overrides(data: Dict[str, Any]) -> Dict[str, Any]: data["active_provider"] = os.environ["COWORK_ACTIVE_PROVIDER"] if os.getenv("COWORK_CA_BUNDLE"): data["tls_ca_bundle"] = os.environ["COWORK_CA_BUNDLE"] - if os.getenv("COWORK_SANDBOX_PASSWORD"): - data["agent_security"]["sandbox_pw"] = os.environ["COWORK_SANDBOX_PASSWORD"] - if os.getenv("COWORK_MS365_UNLOCK_CODE"): - data["ms365"]["unlock_code"] = os.environ["COWORK_MS365_UNLOCK_CODE"] return data diff --git a/docs/Cowork-Local BamBOO.pptx b/docs/Cowork-Local BamBOO.pptx new file mode 100644 index 0000000..eaee484 Binary files /dev/null and b/docs/Cowork-Local BamBOO.pptx differ diff --git a/docs/architecture.html b/docs/architecture.html new file mode 100644 index 0000000..08e9ec4 --- /dev/null +++ b/docs/architecture.html @@ -0,0 +1,327 @@ + + + + + +Cấu trúc hệ thống · Cowork Local + + + +
+ cowork_local · docs + + +
+ +
+
+

Cowork Local · Tài liệu kỹ thuật

+

Cấu trúc hệ thống

+

Trợ lý AI dạng agent chạy cục bộ trên máy (desktop, ưu tiên Windows). Người dùng trò chuyện, chạy luồng nhiều bước, thao tác tệp và lên lịch tác vụ — mọi thứ được bọc trong một khung bảo mật nhiều lớp.

+
+ PySide6 / Qt6 + Local-first + Provider-agnostic + ~53K dòng Python + Windows · macOS · Linux +
+
+ +
+

01 Tổng quan & nguyên tắc

+

Bốn nguyên tắc định hình toàn bộ kiến trúc.

+
+

▤Local-first

Cấu hình, lịch sử hội thoại, workspace và nhật ký đều nằm trên máy người dùng. Chỉ lệnh gọi mô hình mới ra ngoài.

+

⛨Bảo mật nhiều lớp

Mọi tool có tác động (chạy lệnh, ghi tệp, tải URL) đi qua chuỗi kiểm soát fail-closed; xem tài liệu Bảo mật.

+

⧉Đa workspace

Nhiều project chạy song song, mỗi project nhiều hội thoại Cowork và nhiều luồng Co4E — không cái nào chặn cái nào.

+

⇄Provider-agnostic

Nhiều nhà cung cấp mô hình (OpenAI-compatible…), tự động định tuyến chọn mô hình phù hợp trong số các model được bật.

+
+
+ +
+

02 Ngăn xếp công nghệ

+

Những thư viện/thành phần chủ chốt và vai trò của chúng.

+
+ PySide6/Qt6 · toàn bộ giao diện, đa luồng QThread + FastAPI + uvicorn · Routing API (chỉ localhost) + MCP · kết nối công cụ ngoài (Model Context Protocol) + MSAL · đăng nhập Microsoft 365 + openpyxl / python-pptx · đọc Office + opendataloader-pdf · trích xuất PDF + networkx · đồ thị cấu trúc (GraphRAG) + keyring · lưu bí mật qua OS + ctypes / Win32 · sandbox AppContainer & Job Object + Pygments · tô màu mã nguồn +
+
+ +
+

03 Kiến trúc phân lớp

+

Một yêu cầu đi từ giao diện xuống lớp thực thi rồi ra ngoài — mỗi lớp có trách nhiệm rõ ràng.

+
+
+
UI

Lớp giao diện — PySide6

người dùng thao tác
+
+ MainWindowWorkspaceTab / WorkspacePane + CoworkTab (chat)Co4ETab (flow canvas) + FolderTabScheduleTaskTab + MonitoringTab → SecuritySettingsDialog +
+
+
+
Agent

Lớp agent / lõi thực thi

điều phối lượt chạy
+
+ chat_agent · run_cowork + code_agent · run_code + co4e_runner · run_workflow + task_executors · tác vụ theo lịch + model_routing · assess & chọn model + agent_security · guardrail +
+
+
+
Tool

Lớp công cụ & sandbox

ranh giới tin cậy
+
+ ToolContext · confine đường dẫn + scope + execute_tool + read/write/edit/list_dir + run_command · install_package + fetch_url · jira + SandboxManager + backends +
+
+
+
Provider

Lớp nhà cung cấp mô hình

gọi ra mạng an toàn
+
+ providers/* (OpenAI-compatible…) + tls_trust · phục hồi TLS gateway + usage_tracker · đo token/chi phí +
+
+
+
Ngoài

Dịch vụ bên ngoài

không tin cậy mặc định
+
+ LLM APIsMCP servers + Microsoft 365JiraWeb (fetch_url) +
+
+
+
+ +
+

04 Các subsystem chính

+

Mỗi khối là một tính năng lớn người dùng thấy được, ánh xạ tới module tương ứng.

+
+

▦Workspaces & Projects

Nhiều project song song, mỗi cái một pane riêng với sandbox bật/tắt để tiết kiệm tài nguyên.

workspace_tab.pyworkspace_pane.py
+

💬Cowork · đa hội thoại

Nhiều hội thoại trong một project; lượt chạy nền giữ đúng hội thoại gốc kể cả khi bạn chuyển tab.

chat_panel.pycowork_tab.py
+

◈Co4E flows

Canvas nhiều bước, agent tùy biến, chế độ auto/plan/manual, chạy song song & theo dõi ở Flow Status.

co4e_tab.pyco4e_runner.py
+

⇉Model routing

Tự đánh giá & chọn mô hình tốt nhất trong số model được bật theo policy (chất lượng/chi phí/độ trễ).

core/routing/*
+

⛨Sandbox

Chọn backend theo mức rủi ro: best-effort → AppContainer → Windows Sandbox VM.

sandbox_manager.pyappcontainer_sandbox.py
+

⏱Scheduler

Tác vụ theo lịch (Cowork/Code/Flow), phụ thuộc chuỗi, opt-in chạy lệnh.

task_scheduler.pytask_executors.py
+

📊Monitoring

Tổng quan chi phí, nhật ký sự kiện/bảo mật, quản trị Tool/Agent, trang Security.

monitoring_tab.py
+

🗄Lưu trữ

Cấu hình + lịch sử theo project + workspace + audit log, tất cả trên máy.

config.pycore/history.py
+
+
+ +
+

05 Mô hình đồng thời

+

Vì sao nhiều lượt chạy song song không giẫm chân nhau.

+

Cô lập theo lượt (per-turn)

+

Mỗi lượt chat chạy trong một AgentWorker (QThread) riêng. Tại thời điểm bắt đầu, lượt chụp lại bối cảnh home_* (id hội thoại, thư mục làm việc, project) — nên dù người dùng chuyển sang hội thoại khác, lượt nền vẫn ghi kết quả về đúng hội thoại gốc và quét đúng thư mục của nó.

+

Quản lý luồng Co4E dùng chung

+

Một Co4ERunManager duy nhất phục vụ mọi pane, mỗi run gắn project_id để lọc. Khi dừng một worker bị treo, nó được "park" giữ tham chiếu (không GC luồng đang chạy → tránh crash QThread destroyed while running).

+
Cách ly dừng (Stop): nút Stop chỉ tác động lên các worker của chính hội thoại đó và xóa hàng đợi của riêng nó — dừng ở hội thoại này không ảnh hưởng hội thoại khác.
+
+ +
+

06 Luồng dữ liệu một lượt chat

+

Từ tin nhắn người dùng đến kết quả — mỗi bước là một điểm kiểm soát.

+
    +
  1. Tin nhắn + đính kèmNgười dùng gửi; tệp/thư mục workspace được nạp qua _augment.
  2. +
  3. Bọc nội dung không tin cậyNội dung tệp/web/tool được rào trong khối UNTRUSTED DATA — model coi là dữ liệu, không phải mệnh lệnh.
  4. +
  5. Định tuyến mô hìnhAuto Routing có thể chọn mô hình phù hợp trong số model được bật.
  6. +
  7. Gọi providerprovider.chat() qua tls_trust; usage_tracker ghi token/chi phí theo hội thoại gốc.
  8. +
  9. Model gọi toolMỗi tool qua: kiểm scope ở executor → human-gate (nếu bật) → classifier → sandbox.
  10. +
  11. Kết quả & lưuVăn bản/diff hiện realtime; hội thoại lưu vào .cowork_history của project.
  12. +
+
+ +
+

07 Lưu trữ trên máy

+

Dữ liệu nằm ở đâu.

+
+ ~/.cowork_local/config.json · cấu hình (perm 0o600) + <project>/.cowork_history · hội thoại theo project + workspaces/ · thư mục làm việc mỗi project + audit log · mọi tool-call & quyết định quyền (lưu hash lệnh) + trusted_certs/ · cert gateway đã pin + appcontainer_grants.json · cache cấp quyền sandbox +
+
Vị trí lịch sử có thể trỏ vào thư mục đồng bộ OneDrive — tiện chia sẻ, nhưng lưu ý dữ liệu tệp đã nạp sẽ được sao lên cloud dạng plaintext. Xem khuyến nghị ở tài liệu Bảo mật.
+
+
+ + + + + + diff --git a/docs/function_list.md b/docs/function_list.md new file mode 100644 index 0000000..a3a7fec --- /dev/null +++ b/docs/function_list.md @@ -0,0 +1,473 @@ +# 📋 COWORK-LOCAL BamBOO — Danh Sách Chức Năng Chi Tiết Theo Navigation Bar + +--- + +## 🔹 1. 📊 DASHBOARD (Bảng Điều Khiển) + +### 1.1 Token Usage & Cost — Thống Kê Token & Chi Phí + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 1.1.1 | `_refresh_cards()` | Làm mới các thẻ thống kê (Total, Input, Output, Cache tokens + Cost) | +| 1.1.2 | `_refresh_chart()` | Vẽ biểu đồ spline theo chu kỳ (week/month/year) và metric (cost/tokens) | +| 1.1.3 | `_chart_prev()` / `_chart_next()` | Chuyển đến chu kỳ trước/sau trên biểu đồ | +| 1.1.4 | `_on_gran_changed()` | Thay đổi đơn vị thời gian (week/month/year) | +| 1.1.5 | `_refresh_budget()` | Cập nhật ngân sách (budget card — còn lại / đã dùng / cảnh báo >85%) | +| 1.1.6 | `_apply_budget()` | Lưu giá trị budget mới | +| 1.1.7 | `_refresh_habits()` | Hiển thị thói quen sử dụng (task tốn nhiều token nhất, trung bình/prompt, ngày/giờ bận nhất) | +| 1.1.8 | `_ai_analyze()` | ✨ AI phân tích thói quen dùng token và gợi ý tiết kiệm | +| 1.1.9 | `_apply_saving_strategy()` | Áp dụng chiến lược tiết kiệm AI (tự nén context, nén sớm hơn) | +| 1.1.10 | Currency Picker | Chọn đơn vị tiền tệ hiển thị (USD, VND, JPY, …) | + +--- + +## 🔹 2. 📅 SCHEDULE TASK (Lên Lịch Nhiệm Vụ) + +### 2.1 Kanban Board + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.1.1 | `_build_kanban()` | Xây dựng board Kanban với 7 cột: Backlog, Scheduled, Running, Waiting Input, Done, Failed, Paused | +| 2.1.2 | `_render_kanban()` | Render các thẻ task vào từng cột | +| 2.1.3 | `_on_task_dropped(task_id, new_status)` | Kéo thả task giữa các cột (thay đổi status) | +| 2.1.4 | `_on_card_double_click()` | Mở Task Editor khi double-click | +| 2.1.5 | `_on_card_right_click()` | Menu ngữ cảnh: Run now, Edit, Duplicate, Pause, Delete, View logs, Create-next-from-output | +| 2.1.6 | `_bulk_delete_menu()` | Xóa hàng loạt (chọn nhiều thẻ → right-click → Delete N selected) | +| 2.1.7 | `_run_now(task_id)` | Chạy task ngay lập tức | +| 2.1.8 | `_duplicate_task(task_id)` | Sao chép task | +| 2.1.9 | `_pause_task(task_id)` | Tạm dừng task | +| 2.1.10 | `_delete_task(task_id)` | Xóa task | +| 2.1.11 | `_view_logs(task_id)` | Xem log của task | +| 2.1.12 | `_search_tasks()` | Tìm kiếm task theo tên | +| 2.1.13 | `_filter_by_type()` | Lọc task theo loại (cowork/co4e/code/…) | + +### 2.2 Calendar View + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.2.1 | `_build_calendar()` | Xây dựng chế độ xem lịch | +| 2.2.2 | `_shift(direction)` | Chuyển tháng/tuần trước/sau | +| 2.2.3 | `add_task_on_date(date)` | Thêm task vào ngày cụ thể | +| 2.2.4 | `edit_task(task_id)` | Sửa task từ lịch | + +### 2.3 Add / AI Create Task + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.3.1 | `_open_add_dialog()` | Mở dialog thêm task thủ công | +| 2.3.2 | `_ai_create_task()` | Mở dialog AI tạo task tự động | +| 2.3.3 | `_ai_pick_files()` | Chọn file đính kèm cho AI planner | +| 2.3.4 | `_generate()` | AI tạo kế hoạch tasks từ mô tả | +| 2.3.5 | `_on_planned(result)` | Hiển thị preview các task AI đề xuất | +| 2.3.6 | `_confirm()` | Xác nhận và tạo các task từ AI plan | + +### 2.4 AI Import Tasks + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 2.4.1 | `_ai_import()` | AI nhập task từ file/link | +| 2.4.2 | `_ai_pick_import_files()` | Chọn file để import | +| 2.4.3 | `_generate_import()` | AI phân tích file và tạo tasks | +| 2.4.4 | `_on_import_planned()` | Hiển thị preview import | + +--- + +## 🔹 3. 🏠 WORKSPACE (Không Gian Làm Việc) + +### 3.1 Projects — Quản Lý Dự Án (Tab 0) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.1.1 | `_create()` | Tạo dự án mới | +| 3.1.2 | `_delete()` | Xóa dự án (có xác nhận) | +| 3.1.3 | `_save()` | Lưu thông tin dự án (name, description, instructions, folder) | +| 3.1.4 | `_pick_folder()` | Chọn workspace folder cho dự án | +| 3.1.5 | `_open_workspace()` | Mở folder workspace trong file explorer | +| 3.1.6 | `_select_project_row(project_id)` | Chọn dự án trong danh sách | +| 3.1.7 | `_refresh_sandbox_toggle()` | Bật/tắt sandbox cho dự án | +| 3.1.8 | `refresh()` | Làm mới danh sách dự án | + +### 3.2 Workspace Pane — Mỗi Dự Án Mở (Tab 1..N) + +#### 3.2.1 🤖 COWORK — Chat Với AI Agent + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.1.1 | `new_session()` | Tạo phiên chat mới | +| 3.2.1.2 | `send_message()` | Gửi tin nhắn đến AI agent | +| 3.2.1.3 | `_build_job()` | Xây dựng job cho AgentWorker (gọi `run_cowork`) | +| 3.2.1.4 | `_cleanup_turn(ctx, ok)` | Dọn dẹp sau khi turn kết thúc (promote files, xóa sandbox) | +| 3.2.1.5 | `_promote_turn_outputs()` | Di chuyển file đầu ra từ sandbox lên session output | +| 3.2.1.6 | `_refresh_outputs_from_disk()` | Làm mới danh sách output files | +| 3.2.1.7 | `_pick_output_folder()` | Chọn thư mục output | +| 3.2.1.8 | `_open_skills_manager()` | Mở Skill Manager | +| 3.2.1.9 | `refresh_header()` | Làm mới header (project name, model info) | +| 3.2.1.10 | `refresh_agents()` | Làm mới danh sách agents trong combo | +| 3.2.1.11 | `admin_agent_prompt()` | Lấy prompt từ agent preset đã chọn | +| 3.2.1.12 | `build_provider()` | Xây dựng provider từ cấu hình agent/model | +| 3.2.1.13 | `workspace_dir()` | Trả về workspace directory hiện tại | +| 3.2.1.14 | `_start_watching(dir)` | Giám sát folder output (file watcher) | +| 3.2.1.15 | `_on_file_changed()` | Xử lý khi file output thay đổi | + +**ChatPanel (Class cha của CoworkTab):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.1.16 | `_submit_message()` | Gửi tin nhắn (kiểm tra queue, parallel limit) | +| 3.2.1.17 | `_on_turn_started()` | Khi turn bắt đầu (show thinking indicator) | +| 3.2.1.18 | `_on_turn_finished()` | Khi turn kết thúc (update UI, queue next) | +| 3.2.1.19 | `_on_event(ev)` | Xử lý streaming events (text delta, tool calls, plan) | +| 3.2.1.20 | `_compress_messages()` | Nén tin nhắn cũ để giảm token | +| 3.2.1.21 | `_on_agent_changed()` | Khi thay đổi agent trong combo | +| 3.2.1.22 | `_apply_routing()` | Áp dụng model routing (Auto/Manual/Off) | +| 3.2.1.23 | `_note_agent_switch()` | Ghi chú khi agent thay đổi giữa các turn | +| 3.2.1.24 | `_ensure_conversation()` | Đảm bảo conversation tab tồn tại | +| 3.2.1.25 | `load_conversation()` | Load hội thoại từ disk | +| 3.2.1.26 | `running_session_ids()` | Trả về danh sách session đang chạy | +| 3.2.1.27 | `active_workers()` | Trả về danh sách worker đang hoạt động | +| 3.2.1.28 | `_save_conversation()` | Tự động lưu hội thoại | + +**Composer (Composer input box):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.1.29 | `send()` | Gửi tin nhắn | +| 3.2.1.30 | `attach_files()` | Đính kèm file | +| 3.2.1.31 | `attach_links()` | Đính kèm link URL | +| 3.2.1.32 | `has_any_queue()` | Kiểm tra queue có tin nhắn chờ | +| 3.2.1.33 | `_parse_directives()` | Phân tích directives inline (`/agent:name`, `/skill:name`) | +| 3.2.1.34 | `_show_autocomplete()` | Hiển thị gợi ý tự động | + +#### 3.2.2 ⚡ CO4E — Node-Graph Workflow Studio + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.1 | `_build_sidebar()` | Xây dựng sidebar (Workflows / Agents / Skills tabs) | +| 3.2.2.2 | `_build_canvas()` | Xây dựng canvas node-graph | +| 3.2.2.3 | `_build_config_panel()` | Xây dựng config panel bên phải | +| 3.2.2.4 | `_toggle_config()` | Thu/mở config panel | +| 3.2.2.5 | `_build_canvas_overlay()` | Zoom +/− và Fit buttons trên canvas | + +**Workflows (Sidebar):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.6 | `_refresh_flows_list()` | Làm mới danh sách flows | +| 3.2.2.7 | `_create_flow()` | Tạo flow mới | +| 3.2.2.8 | `_delete_flow()` | Xóa flow | +| 3.2.2.9 | `_duplicate_flow()` | Sao chép flow | +| 3.2.2.10 | `_import_flow()` | Import flow từ file | +| 3.2.2.11 | `_export_flow()` | Export flow ra file | +| 3.2.2.12 | `_run_flow()` | Chạy flow (foreground/background) | +| 3.2.2.13 | `_stop_flow()` | Dừng flow đang chạy | +| 3.2.2.14 | `_open_flow()` | Mở flow trên canvas | + +**Agents (Sidebar):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.15 | `_refresh_agents_list()` | Làm mới danh sách agents | +| 3.2.2.16 | `_create_agent()` | Tạo agent mới (dialog) | +| 3.2.2.17 | `_edit_agent()` | Sửa agent | +| 3.2.2.18 | `_delete_agent()` | Xóa agent | +| 3.2.2.19 | `_toggle_agent_enabled()` | Bật/tắt agent | + +**Skills (Sidebar):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.20 | `_refresh_skills_list()` | Làm mới danh sách skills | + +**Canvas (Node-Graph):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.21 | `zoom_in()` / `zoom_out()` | Zoom canvas | +| 3.2.2.22 | `fit_view()` | Auto-fit canvas | +| 3.2.2.23 | `_add_node()` | Thêm node lên canvas | +| 3.2.2.24 | `_delete_node()` | Xóa node | +| 3.2.2.25 | `_connect_nodes()` | Kết nối 2 nodes | +| 3.2.2.26 | `_drag_node()` | Kéo thả node | +| 3.2.2.27 | `_select_node()` | Chọn node (→ config panel) | +| 3.2.2.28 | `_activate_node()` | Double-click node | + +**Run Modes:** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.29 | `_set_run_mode("auto")` | Auto mode: agent tự plan rồi execute | +| 3.2.2.30 | `_set_run_mode("plan")` | Plan mode: chỉ tạo kế hoạch | +| 3.2.2.31 | `_set_run_mode("manual")` | Manual mode: từng bước, bấm "Next step" | +| 3.2.2.32 | `_run_step()` | Chạy bước tiếp theo (manual mode) | +| 3.2.2.33 | `_on_step_finished()` | Xử lý khi bước hoàn thành | +| 3.2.2.34 | `_render_plan()` | Render plan checklist | + +**Chat/Output (Bottom):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.2.35 | `_get_flow_chat(flow_id)` | Lấy ChatView cho flow (tạo mới nếu chưa có) | +| 3.2.2.36 | `_on_chat_event()` | Xử lý event từ chat | + +#### 3.2.3 📁 FOLDER — File Explorer + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.3.1 | `set_root(path)` | Đặt thư mục gốc | +| 3.2.3.2 | `_build_tree_view()` | Xây dựng cây thư mục (QFileSystemModel) | +| 3.2.3.3 | `_open_file(path)` | Mở file được chọn | +| 3.2.3.4 | `_view_source()` | Xem source code (syntax highlighting) | +| 3.2.3.5 | `_view_html_preview()` | Preview HTML (WebEngine/rich text) | +| 3.2.3.6 | `_view_office_doc()` | Xem Office doc (docx/pdf/xlsx/…) | +| 3.2.3.7 | `_view_image()` | Hiển thị ảnh inline | +| 3.2.3.8 | `_edit_file()` | Chỉnh sửa file (code editor) | +| 3.2.3.9 | `_save_file()` | Lưu file | +| 3.2.3.10 | `_preview_toggle()` | Chuyển đổi Preview ⇄ Edit | +| 3.2.3.11 | `_create_new_file()` | Tạo file mới | +| 3.2.3.12 | `_create_new_folder()` | Tạo folder mới | +| 3.2.3.13 | `_rename_item()` | Đổi tên file/folder | +| 3.2.3.14 | `_delete_item()` | Xóa file/folder | +| 3.2.3.15 | `_copy_item()` | Sao chép file/folder | +| 3.2.3.16 | `_paste_item()` | Dán file/folder | +| 3.2.3.17 | `refresh_ai_models()` | Làm mới danh sách AI models cho AI Edit | + +**AI Edit (Chỉnh Sửa File Bằng AI):** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.3.18 | `_ai_send()` | Gửi yêu cầu AI edit | +| 3.2.3.19 | `_ai_apply()` | Áp dụng thay đổi AI | +| 3.2.3.20 | `_ai_discard()` | Hủy thay đổi AI | +| 3.2.3.21 | `_reset_ai_conversation()` | Xóa hội thoại AI edit | +| 3.2.3.22 | `_ai_apply_routing()` | Áp dụng routing cho AI edit | + +#### 3.2.4 🧠 GRAPH RAG — Knowledge Graph + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.4.1 | `_build_graph()` | Xây dựng knowledge graph từ code/documents | +| 3.2.4.2 | `_render_d3_graph()` | Render graph bằng D3.js (WebEngine) | +| 3.2.4.3 | `_render_native_graph()` | Render graph bằng QGraphicsView (fallback) | +| 3.2.4.4 | `_auto_rotate()` | Tự xoay graph khi idle | +| 3.2.4.5 | `_on_node_click()` | Xử lý click node (mở folder) | +| 3.2.4.6 | `_open_node_path()` | Mở folder chứa node | +| 3.2.4.7 | `_refresh_graph()` | Tự cập nhật graph khi có output mới | +| 3.2.4.8 | `_search_graph()` | Tìm kiếm trong graph | +| 3.2.4.9 | `_filter_by_kind()` | Lọc node theo loại | +| 3.2.4.10 | `_zoom_graph()` | Zoom graph | + +**Graph-RAG Q&A:** + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 3.2.4.11 | `_ask_question()` | Hỏi AI về graph | +| 3.2.4.12 | `_on_ask_event()` | Xử lý streaming answer | +| 3.2.4.13 | `_on_ask_done()` | Khi AI trả lời xong | +| 3.2.4.14 | `_candidate_file_paths()` | Lấy danh sách file để extract | +| 3.2.4.15 | `_extract_tmp_dir()` | Tạo thư mục tạm cho extraction | +| 3.2.4.16 | `_clear_extracts()` | Xóa dữ liệu extract tạm | + +--- + +## 🔹 4. 📊 MONITORING (Giám Sát) + +### 4.1 Overview — Tổng Quan + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.1.1 | `_refresh_overview()` | Làm mới tất cả cards overview | +| 4.1.2 | `_refresh_usage_cards()` | Token Usage & Cost cards (Total, Input, Output, Cache) | +| 4.1.3 | `_refresh_resource_usage()` | Resource usage (CPU, RAM, Disk) | +| 4.1.4 | `_refresh_recent_activity()` | Hoạt động gần đây | +| 4.1.5 | `_refresh_sandbox_details()` | Chi tiết sandbox (PID, uptime, limits) | +| 4.1.6 | `_refresh_permissions()` | Hiển thị permissions hiện tại | +| 4.1.7 | `_refresh_audit_log()` | Audit log gần đây | +| 4.1.8 | `_refresh_budget()` | Budget card (còn lại / đã dùng) | +| 4.1.9 | `_apply_budget()` | Lưu budget mới | + +### 4.2 Security Events — Sự Kiện Bảo Mật + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.2.1 | `_refresh_security_events()` | Làm mới bảng security events (audit log `kind="security_block"`) | +| 4.2.2 | `_filter_security_events()` | Lọc sự kiện bảo mật | +| 4.2.3 | `_sort_events()` | Sắp xếp bảng events | + +### 4.3 MCP Call History — Lịch Sử Gọi MCP + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.3.1 | `_refresh_mcp_calls()` | Làm mới bảng MCP calls (audit log `kind="mcp_call"`) | +| 4.3.2 | `_filter_mcp_calls()` | Lọc MCP calls | + +### 4.4 Action Logs — Nhật Ký Hành Động + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.4.1 | `_refresh_action_logs()` | Làm mới bảng action logs (toàn bộ audit log) | +| 4.4.2 | `_filter_action_logs()` | Lọc action logs | +| 4.4.3 | `_sort_action_logs()` | Sắp xếp action logs | + +### 4.5 Agent Status — Trạng Thái Agent + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.5.1 | `_refresh_agent_status()` | Làm mới trạng thái các agent (Cowork, Co4E, Schedule, GraphRAG) | + +### 4.6 Security Settings — Cài Đặt Bảo Mật + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 4.6.1 | `_toggle_sandbox()` | Bật/tắt sandbox | +| 4.6.2 | `_toggle_network_block()` | Chặn kết nối mạng | +| 4.6.3 | `_set_resource_limits()` | Đặt giới hạn tài nguyên (CPU/RAM/Disk) | +| 4.6.4 | `_toggle_command_confirm()` | Xác nhận trước khi chạy lệnh | +| 4.6.5 | `_manage_permissions()` | Quản lý quyền truy cập | + +--- + +## 🔹 5. ⚙️ SETTINGS (Cài Đặt) + +### 5.1 AI Provider — Nhà Cung Cấp AI + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.1.1 | `_on_provider_changed()` | Khi thay đổi provider | +| 5.1.2 | `_load_models(provider)` | Load danh sách models của provider | +| 5.1.3 | `_test_connection(provider)` | Kiểm tra kết nối provider | +| 5.1.4 | `_stash_provider_fields()` | Lưu tạm các trường cấu hình provider | +| 5.1.5 | `_apply_provider_fields()` | Áp dụng các trường cấu hình provider | +| 5.1.6 | Model List Widget | Hiển thị danh sách models (enable/disable, chọn default) | + +### 5.2 Connectors (MCP) — Kết Nối + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.2.1 | `_add_mcp_server()` | Thêm MCP server mới | +| 5.2.2 | `_edit_mcp_server()` | Sửa MCP server | +| 5.2.3 | `_delete_mcp_server()` | Xóa MCP server | +| 5.2.4 | `_test_mcp_connection()` | Kiểm tra kết nối MCP | +| 5.2.5 | MS365 Connector | Kết nối Microsoft 365 (tự động khi đăng nhập) | +| 5.2.6 | CAD/CAE Connectors | Kết nối CAD/CAE tools | + +### 5.3 Parameters — Tham Số + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.3.1 | `attach_tokens` | Giới hạn token cho attachments | +| 5.3.2 | `attach_files` | Giới hạn số file attachments | +| 5.3.3 | `struct_nodes` | Giới hạn nodes cho GraphRAG | +| 5.3.4 | `struct_edges` | Giới hạn edges cho GraphRAG | + +### 5.4 Model Routing — Định Tuyến Model + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.4.1 | `routing_mode` | Chế độ routing (Off/Auto/Manual) | +| 5.4.2 | `routing_policy` | Chính sách routing | +| 5.4.3 | `routing_min_gain` | Threshold tối thiểu để chuyển model | +| 5.4.4 | `routing_timeout` | Timeout xác nhận routing | +| 5.4.5 | `routing_interval` | Khoảng thời gian đánh giá lại | +| 5.4.6 | `routing_concurrency` | Số lượng request đồng thời per provider | +| 5.4.7 | `routing_judge` | Model dùng để đánh giá routing | + +### 5.5 General — Chung + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 5.5.1 | Language Picker | Chọn ngôn ngữ (EN/VI/JP) | +| 5.5.2 | `tray_chk` | Minimize to tray thay vì đóng | +| 5.5.3 | `notify_chk` | Thông báo khi task hoàn thành | +| 5.5.4 | `_save()` | Lưu tất cả cài đặt | + +--- + +## 🔹 6. 📜 HISTORY SIDEBAR (Thanh Lịch Sử Bên Trái) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 6.0.1 | `refresh()` | Làm mới danh sách hội thoại | +| 6.0.2 | `set_view_state(session_id, running_ids)` | Đánh dấu hội thoại hiện tại + đang chạy | +| 6.0.3 | `set_project_filter(project_id)` | Lọc theo dự án | +| 6.0.4 | `_open_chat()` | Mở hội thoại khi click | +| 6.0.5 | `_context_menu()` | Menu chuột phải (Pin/Unpin, Rename, Delete) | +| 6.0.6 | `_bulk_delete_menu()` | Menu xóa hàng loạt | +| 6.0.7 | `_confirm_and_delete_selected()` | Xác nhận và xóa các hội thoại đã chọn | +| 6.0.8 | `new_chat(kind)` | Tạo hội thoại mới | +| 6.0.9 | `collapse_requested()` | Thu nhỏ sidebar | +| 6.0.10 | `expand_requested()` | Mở rộng sidebar | + +--- + +## 🔹 7. 🧩 CÁC CHỨC NĂNG TOÀN CẦU (Global) + +### 7.1 MainWindow (app.py) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.1.1 | `_build_topbar()` | Xây dựng thanh trên cùng (User name, Settings, Language) | +| 7.1.2 | `_build_nav_rail()` | Xây dựng thanh điều hướng bên trái | +| 7.1.3 | `_toggle_nav()` | Thu/mở nav rail (icon-only ↔ full) | +| 7.1.4 | `_apply_nav_labels()` | Áp dụng labels cho nav items | +| 7.1.5 | `_ensure_page(row)` | Xây dựng page lười (lazy loading) | +| 7.1.6 | `_refresh_history()` | Làm mới history của tất cả panes | +| 7.1.7 | `_on_scheduled_task_done()` | Thông báo khi scheduled task hoàn thành | +| 7.1.8 | `_notify_task()` | Thông báo khi task hoàn thành | +| 7.1.9 | `_on_projects_changed()` | Khi danh sách dự án thay đổi | +| 7.1.10 | `_on_pane_turn_finished()` | Khi turn trong pane hoàn thành | +| 7.1.11 | `_open_settings()` | Mở dialog cài đặt | +| 7.1.12 | `_fit_to_screen()` | Tự động fit cửa sổ theo màn hình | +| 7.1.13 | Toast notifications | Hiển thị thông báo toast | +| 7.1.14 | System Tray | Minimize to tray, tray notifications | + +### 7.2 Skills Manager + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.2.1 | `_open_skills_manager()` | Mở Skill Manager | +| 7.2.2 | `seed_library_skills()` | Gieo skills mặc định | +| 7.2.3 | `prune_seeded_builtins()` | Dọn dẹp skills built-in | + +### 7.3 Welcome Dialog + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.3.1 | `maybe_show_welcome()` | Hiển thị dialog chào mừng lần đầu | + +### 7.4 i18n (Đa Ngôn Ngữ) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.4.1 | `tr(key)` | Dịch chuỗi theo ngôn ngữ hiện tại | +| 7.4.2 | `set_language(lang)` | Đặt ngôn ngữ | +| 7.4.3 | `get_language()` | Lấy ngôn ngữ hiện tại | +| 7.4.4 | `on_language_changed(callback)` | Đăng ký callback khi ngôn ngữ thay đổi | + +### 7.5 Task Scheduler (Nền) + +| # | Tên Hàm / Chức Năng | Mô Tả | +|---|---------------------|--------| +| 7.5.1 | `task_finished` signal | Khi scheduled task hoàn thành | +| 7.5.2 | `history_ready` signal | Khi session của task sẵn sàng | +| 7.5.3 | `running_session_ids()` | Lấy danh sách session đang chạy | + +--- + +## 📌 TỔNG KẾT + +| Navigation Item | Số Hàm/Chức Năng | +|----------------|:-:| +| 📊 Dashboard | ~10 | +| 📅 Schedule Task | ~25 | +| 🏠 Workspace → Projects | ~8 | +| 🏠 Workspace → Cowork | ~34 | +| 🏠 Workspace → Co4E | ~36 | +| 🏠 Workspace → Folder | ~22 | +| 🏠 Workspace → Graph RAG | ~16 | +| 📊 Monitoring | ~20 | +| ⚙️ Settings | ~25 | +| 📜 History Sidebar | ~10 | +| 🌐 Global Functions | ~15 | +| **TỔNG CỘNG** | **~221** | + +> **Lưu ý:** Đây là danh sách các hàm/chức năng ở cấp UI và business logic chính. Các hàm core (providers, MCP, worker, security…) nằm ở tầng dưới và được gọi bởi các hàm UI ở trên. \ No newline at end of file diff --git a/docs/screens/controls.json b/docs/screens/controls.json new file mode 100644 index 0000000..c405af3 --- /dev/null +++ b/docs/screens/controls.json @@ -0,0 +1,4208 @@ +[ + { + "file": "ui\\accounts_tab.py", + "controls": [ + { + "var": "self.user_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.username if account else ''", + "line": 108, + "signals": [], + "object_name": "" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.display_name if account else ''", + "line": 111, + "signals": [], + "object_name": "" + }, + { + "var": "self.email_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.email if account else ''", + "line": 113, + "signals": [], + "object_name": "" + }, + { + "var": "self.dept_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "account.department if account else ''", + "line": 125, + "signals": [], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 137, + "signals": [], + "object_name": "" + }, + { + "var": "self.search_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "keyword", + "line": 176, + "signals": [ + "textChanged → self._apply_tree_filter" + ], + "object_name": "" + }, + { + "var": "self.ai_search_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.ai_search_btn')", + "line": 178, + "signals": [ + "clicked → self._ai_search" + ], + "object_name": "", + "label_vi": "AI" + }, + { + "var": "self.group_filter_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 188, + "signals": [ + "currentIndexChanged → lambda _i: self._apply_tree_filter()" + ], + "object_name": "" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.add_btn')", + "line": 202, + "signals": [ + "clicked → self._add_account" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.edit_btn')", + "line": 205, + "signals": [ + "clicked → self._edit_account" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.delete_btn')", + "line": 208, + "signals": [ + "clicked → self._delete_account" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.code_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.generate_code_btn')", + "line": 211, + "signals": [ + "clicked → self._regenerate_code" + ], + "object_name": "", + "label_vi": "Tạo mã" + }, + { + "var": "self.group_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.new_group_btn')", + "line": 221, + "signals": [ + "clicked → self._add_group" + ], + "object_name": "", + "label_vi": "Nhóm mới" + }, + { + "var": "self.excel_template_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.excel_template_btn')", + "line": 230, + "signals": [ + "clicked → self._export_excel_template" + ], + "object_name": "", + "label_vi": "Mẫu Excel" + }, + { + "var": "self.excel_import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('accounts.excel_import_btn')", + "line": 233, + "signals": [ + "clicked → self._import_excel" + ], + "object_name": "", + "label_vi": "Nhập từ Excel" + }, + { + "var": "self.period_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 251, + "signals": [ + "currentIndexChanged → self.refresh" + ], + "object_name": "" + }, + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.refresh')", + "line": 256, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "", + "label_vi": "Làm mới" + }, + { + "var": "self.usage_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 262, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\agent_manager_tab.py", + "controls": [ + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.delete_btn')", + "line": 52, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.name", + "line": 62, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.description", + "line": 63, + "signals": [], + "object_name": "" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 68, + "signals": [ + "currentIndexChanged → self._reload_models" + ], + "object_name": "" + }, + { + "var": "self._gen_prompt_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.gen_prompt_btn')", + "line": 88, + "signals": [ + "clicked → self._gen_prompt" + ], + "object_name": "", + "label_vi": "Tạo prompt từ mô tả" + }, + { + "var": "new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.new_btn')", + "line": 107, + "signals": [ + "clicked → self._new_agent" + ], + "object_name": "", + "label_vi": "Agent mới" + }, + { + "var": "save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agentmgr.save_btn')", + "line": 110, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu agent" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\agents_admin_tab.py", + "controls": [ + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.name if agent else ''", + "line": 55, + "signals": [], + "object_name": "" + }, + { + "var": "self.prompt_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "agent.prompt if agent else ''", + "line": 65, + "signals": [], + "object_name": "" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 70, + "signals": [ + "currentIndexChanged → self._refresh_model_combo" + ], + "object_name": "" + }, + { + "var": "self.load_models_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.load_models_tooltip')", + "line": 89, + "signals": [ + "clicked → self._load_live_models" + ], + "object_name": "", + "label_vi": "Lấy danh sách model thực tế của provider này để chọn từ dropdown." + }, + { + "var": "self.enabled_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('agents_admin.f_enabled')", + "line": 98, + "signals": [], + "object_name": "", + "label_vi": "Kích hoạt" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 101, + "signals": [], + "object_name": "" + }, + { + "var": "self.table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 169, + "signals": [], + "object_name": "" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.add_btn')", + "line": 178, + "signals": [ + "clicked → self._add" + ], + "object_name": "primary", + "label_vi": "Thêm" + }, + { + "var": "self.edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.edit_btn')", + "line": 182, + "signals": [ + "clicked → self._edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.delete_btn')", + "line": 185, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.check_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('agents_admin.check_btn')", + "line": 188, + "signals": [ + "clicked → self._check_all" + ], + "object_name": "", + "label_vi": "Kiểm tra" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\calendar_view.py", + "controls": [ + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "'+'", + "line": 43, + "signals": [ + "clicked → lambda: self.add_requested.emit(self._date_str)" + ], + "object_name": "" + }, + { + "var": "self.list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 49, + "signals": [ + "itemClicked → self._on_item_clicked" + ], + "object_name": "" + }, + { + "var": "self.prev_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.cal_prev')", + "line": 100, + "signals": [ + "clicked → lambda: self._shift(-1)" + ], + "object_name": "", + "label_vi": "Trước" + }, + { + "var": "self.today_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.cal_today')", + "line": 103, + "signals": [ + "clicked → self._go_today" + ], + "object_name": "", + "label_vi": "Hôm nay" + }, + { + "var": "self.next_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.cal_next')", + "line": 105, + "signals": [ + "clicked → lambda: self._shift(1)" + ], + "object_name": "", + "label_vi": "Sau" + }, + { + "var": "self.granularity_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 110, + "signals": [ + "currentIndexChanged → self._on_granularity_changed" + ], + "object_name": "" + }, + { + "var": "lst", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 213, + "signals": [ + "itemClicked → lambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole))" + ], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\chat_panel.py", + "controls": [ + { + "var": "self.agent_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('chatpanel.agent_tooltip')", + "line": 165, + "signals": [ + "currentIndexChanged → self._on_agent_changed" + ], + "object_name": "", + "label_vi": "Model/agent riêng cho tab này — độc lập với tab kia" + }, + { + "var": "self.compress_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('chatpanel.compress_btn')", + "line": 177, + "signals": [ + "clicked → self._compress_messages" + ], + "object_name": "", + "label_vi": "Nén" + }, + { + "var": "self._io_collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('chatpanel.collapse_files_tooltip')", + "line": 239, + "signals": [ + "clicked → lambda: self._set_io_collapsed(True)" + ], + "object_name": "", + "label_vi": "Thu gọn bảng Files" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "app_icon('link')", + "line": 439 + }, + { + "menu": "menu", + "label": "app_icon('edit')", + "line": 440 + } + ] + }, + { + "file": "ui\\chat_view.py", + "controls": [ + { + "var": "self._head", + "type": "QPushButton", + "kind": "nút", + "label": "title", + "line": 228, + "signals": [ + "clicked → self._toggle_body" + ], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\co4e_agent_dialog.py", + "controls": [ + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.name", + "line": 32, + "signals": [], + "object_name": "" + }, + { + "var": "self.role_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "agent.role or 'AGENT'", + "line": 34, + "signals": [], + "object_name": "" + }, + { + "var": "self.instructions_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "agent.instructions", + "line": 42, + "signals": [], + "object_name": "" + }, + { + "var": "self.gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.ai_draft')", + "line": 46, + "signals": [ + "clicked → self._ai_draft" + ], + "object_name": "", + "label_vi": "Soạn bằng AI" + }, + { + "var": "self.context_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "getattr(agent, 'context', '')", + "line": 59, + "signals": [], + "object_name": "" + }, + { + "var": "self.load_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.load_models_tooltip')", + "line": 68, + "signals": [ + "clicked → self._load_models" + ], + "object_name": "", + "label_vi": "Tải danh sách model" + }, + { + "var": "attach_add", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_add')", + "line": 99, + "signals": [ + "clicked → self._add_attachment" + ], + "object_name": "", + "label_vi": "Đính kèm tệp" + }, + { + "var": "attach_del", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_remove')", + "line": 102, + "signals": [ + "clicked → self._del_attachment" + ], + "object_name": "", + "label_vi": "Bỏ" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 113, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\co4e_canvas.py", + "controls": [], + "menu_actions": [ + { + "menu": "menu", + "label": "'+ Add next step'", + "line": 188 + }, + { + "menu": "menu", + "label": "'→ Connect from here'", + "line": 189 + }, + { + "menu": "menu", + "label": "'🗑 Delete step'", + "line": 190 + }, + { + "menu": "menu", + "label": "'🗑 Delete connection'", + "line": 368 + } + ] + }, + { + "file": "ui\\co4e_config_panel.py", + "controls": [ + { + "var": "self.label_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.label", + "line": 44, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.role_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.role", + "line": 48, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.instructions_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "", + "line": 60, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.ai_draft')", + "line": 63, + "signals": [ + "clicked → self._ai_draft" + ], + "object_name": "", + "label_vi": "Soạn bằng AI" + }, + { + "var": "self.context_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('co4e.f_context_placeholder')", + "line": 77, + "signals": [ + "textChanged → self._on_edit" + ], + "object_name": "", + "label_vi": "Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vào prompt khi chạy)." + }, + { + "var": "self.load_models_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.load_models_tooltip')", + "line": 87, + "signals": [ + "clicked → self._load_models" + ], + "object_name": "", + "label_vi": "Tải danh sách model" + }, + { + "var": "self.perm_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 97, + "signals": [ + "currentIndexChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.verify_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('co4e.f_self_verify')", + "line": 104, + "signals": [ + "toggled → self._on_edit" + ], + "object_name": "", + "label_vi": "Tự kiểm tra" + }, + { + "var": "self.rounds_spin", + "type": "QSpinBox", + "kind": "ô số", + "label": "", + "line": 106, + "signals": [ + "valueChanged → self._on_edit" + ], + "object_name": "" + }, + { + "var": "self.attach_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_add')", + "line": 125, + "signals": [ + "clicked → self._add_attachment" + ], + "object_name": "", + "label_vi": "Đính kèm tệp" + }, + { + "var": "self.attach_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.attach_remove')", + "line": 128, + "signals": [ + "clicked → self._del_attachment" + ], + "object_name": "", + "label_vi": "Bỏ" + }, + { + "var": "self.sub_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 141, + "signals": [ + "itemDoubleClicked → self._edit_subagent" + ], + "object_name": "" + }, + { + "var": "self.sub_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.add_subagent')", + "line": 144, + "signals": [ + "clicked → self._add_subagent" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.sub_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.del_subagent')", + "line": 147, + "signals": [ + "clicked → self._del_subagent" + ], + "object_name": "", + "label_vi": "Bỏ" + }, + { + "var": "self.run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run')", + "line": 159, + "signals": [ + "clicked → lambda: self.run_node.emit(self._node_id)" + ], + "object_name": "", + "label_vi": "Chạy" + }, + { + "var": "self.run_from_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run_from_here')", + "line": 163, + "signals": [ + "clicked → lambda: self.run_from.emit(self._node_id)" + ], + "object_name": "", + "label_vi": "Chạy từ đây" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.delete_step')", + "line": 166, + "signals": [ + "clicked → lambda: self.delete_node.emit(self._node_id)" + ], + "object_name": "danger", + "label_vi": "Xóa bước" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\co4e_tab.py", + "controls": [ + { + "var": "self._popup", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 144, + "signals": [ + "itemClicked → lambda _i: self._accept()" + ], + "object_name": "" + }, + { + "var": "btn", + "type": "QPushButton", + "kind": "nút", + "label": "'×'", + "line": 341, + "signals": [ + "clicked → lambda: self._close_flow_tab_button(btn)" + ], + "object_name": "flowTabClose" + }, + { + "var": "self.wf_runbg_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run_bg')", + "line": 459, + "signals": [ + "clicked → self._run_selected_in_background" + ], + "object_name": "", + "label_vi": "Chạy" + }, + { + "var": "self.ag_new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.new')", + "line": 476, + "signals": [ + "clicked → self._new_agent" + ], + "object_name": "", + "label_vi": "Mới" + }, + { + "var": "self.sk_manage_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.manage_skills')", + "line": 493, + "signals": [ + "clicked → self._manage_skills" + ], + "object_name": "", + "label_vi": "Quản lý skill…" + }, + { + "var": "b", + "type": "QPushButton", + "kind": "nút", + "label": "tr(tip_key)", + "line": 502, + "signals": [ + "clicked → slot" + ], + "object_name": "" + }, + { + "var": "self.flow_bar", + "type": "QTabBar", + "kind": "dải tab", + "label": "", + "line": 556, + "signals": [ + "currentChanged → self._on_flow_tab_changed", + "tabCloseRequested → self._close_flow_tab" + ], + "object_name": "flowTabs" + }, + { + "var": "self.flow_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "'+'", + "line": 582, + "signals": [ + "clicked → self._new_workflow" + ], + "object_name": "flowAddBtn" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self._wf.name", + "line": 631, + "signals": [ + "textChanged → self._on_name_changed" + ], + "object_name": "" + }, + { + "var": "self.add_step_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.add')", + "line": 636, + "signals": [ + "clicked → self._add_blank_step" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.save')", + "line": 639, + "signals": [ + "clicked → lambda: self._save(as_template=False)" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.mode_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('co4e.tt_mode')", + "line": 645, + "signals": [ + "currentIndexChanged → self._on_mode_changed" + ], + "object_name": "", + "label_vi": "Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kế hoạch (chỉ đọc) · Manual = từng bước (bấm Bước tiếp)" + }, + { + "var": "self.run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.run')", + "line": 650, + "signals": [ + "clicked → self._on_run_clicked" + ], + "object_name": "primary", + "label_vi": "Chạy" + }, + { + "var": "self.ws_folder_btn", + "type": "QPushButton", + "kind": "nút", + "label": "short", + "line": 693, + "signals": [ + "clicked → self._open_workspace_folder" + ], + "object_name": "" + }, + { + "var": "self.run_stop_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.stop')", + "line": 701, + "signals": [ + "clicked → self._stop_selected_run" + ], + "object_name": "danger", + "label_vi": "Dừng" + }, + { + "var": "self.run_rename_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.rename_run')", + "line": 706, + "signals": [ + "clicked → self._rename_selected_run" + ], + "object_name": "", + "label_vi": "Đổi tên" + }, + { + "var": "self.run_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.delete_run')", + "line": 710, + "signals": [ + "clicked → self._delete_selected_run" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.run_clear_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.clear_done')", + "line": 714, + "signals": [ + "clicked → lambda: self.manager.clear_finished()" + ], + "object_name": "", + "label_vi": "Xóa đã xong" + }, + { + "var": "self.runs_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 722, + "signals": [ + "itemDoubleClicked → self._open_run_from_table", + "customContextMenuRequested → self._runs_context_menu" + ], + "object_name": "" + }, + { + "var": "self.config_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.tt_collapse_config')", + "line": 747, + "signals": [ + "clicked → self._toggle_config" + ], + "object_name": "", + "label_vi": "Thu gọn bảng cấu hình" + }, + { + "var": "self.chat_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.tt_expand_msgs')", + "line": 841, + "signals": [ + "clicked → self._toggle_messages" + ], + "object_name": "msgToggle", + "label_vi": "Mở rộng khung tin nhắn" + }, + { + "var": "self.chat_send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('co4e.send')", + "line": 873, + "signals": [ + "clicked → self._chat_send" + ], + "object_name": "", + "label_vi": "Gửi" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "icon('edit')", + "line": 1014 + }, + { + "menu": "menu", + "label": "icon('edit')", + "line": 1015 + }, + { + "menu": "menu", + "label": "icon('branch')", + "line": 1016 + }, + { + "menu": "menu", + "label": "icon('play')", + "line": 1017 + }, + { + "menu": "menu", + "label": "icon('trash')", + "line": 1018 + }, + { + "menu": "menu", + "label": "tr('co4e.open_run')", + "line": 1400, + "label_vi": "Mở flow" + }, + { + "menu": "menu", + "label": "tr('co4e.open_output')", + "line": 1404, + "label_vi": "Mở thư mục output" + }, + { + "menu": "menu", + "label": "tr('co4e.rename_run')", + "line": 1405, + "label_vi": "Đổi tên" + }, + { + "menu": "menu", + "label": "tr('co4e.delete_run')", + "line": 1406, + "label_vi": "Xóa" + } + ] + }, + { + "file": "ui\\composer.py", + "controls": [ + { + "var": "self.queue_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "tr('composer.queue_tooltip')", + "line": 406, + "signals": [ + "itemDoubleClicked → self._remove_queue_item" + ], + "object_name": "", + "label_vi": "Nhấp đúp để xoá một tin nhắn khỏi hàng đợi" + }, + { + "var": "self.attach_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "tr('composer.attachments_tooltip')", + "line": 420, + "signals": [ + "itemDoubleClicked → self._remove_attachment" + ], + "object_name": "", + "label_vi": "Bấm trên thẻ để gỡ tệp đính kèm nhầm" + }, + { + "var": "self.attach_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 443, + "signals": [ + "clicked → self._pick_attachments" + ], + "object_name": "" + }, + { + "var": "self.send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('composer.queue_btn') if self._busy else tr('composer.send')", + "line": 446, + "signals": [ + "clicked → self._on_submit" + ], + "object_name": "primary" + }, + { + "var": "self.stop_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('composer.stop')", + "line": 450, + "signals": [ + "clicked → self.stop_requested.emit" + ], + "object_name": "danger", + "label_vi": "Dừng" + }, + { + "var": "remove", + "type": "QPushButton", + "kind": "nút", + "label": "tr('composer.remove_tooltip')", + "line": 599, + "signals": [ + "clicked → lambda _=False, path=p: self._remove_attachment_path(path)" + ], + "object_name": "danger", + "label_vi": "Gỡ tệp này (đính kèm nhầm)" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\connectors_panel.py", + "controls": [ + { + "var": "self.paste", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('connectors.jira_paste_placeholder')", + "line": 42, + "signals": [ + "textChanged → self._on_paste" + ], + "object_name": "", + "label_vi": "Dán bất kỳ link Jira nào — tự điền Base URL" + }, + { + "var": "self.url", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "jira.get('base_url', '')", + "line": 46, + "signals": [], + "object_name": "" + }, + { + "var": "self.email", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "jira.get('email', '')", + "line": 48, + "signals": [], + "object_name": "" + }, + { + "var": "self.token", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "jira.get('api_token', '')", + "line": 49, + "signals": [], + "object_name": "" + }, + { + "var": "self.test_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('connectors.jira_test')", + "line": 58, + "signals": [ + "clicked → self._test" + ], + "object_name": "", + "label_vi": "Kiểm tra kết nối" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('connectors.jira_save')", + "line": 60, + "signals": [ + "clicked → self._save_close" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.connect_external_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('connectors.connect_external')", + "line": 133, + "signals": [ + "toggled → self._on_connect_external_toggled" + ], + "object_name": "", + "label_vi": "Kết nối tới connector bên ngoài" + }, + { + "var": "self.ext_tree", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 144, + "signals": [ + "itemDoubleClicked → lambda *_: self._ext_edit()" + ], + "object_name": "" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ext_add_btn')", + "line": 155, + "signals": [ + "clicked → self._ext_add" + ], + "object_name": "primary", + "label_vi": "Thêm connector…" + }, + { + "var": "self.edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ext_edit_btn')", + "line": 159, + "signals": [ + "clicked → self._ext_edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ext_delete_btn')", + "line": 162, + "signals": [ + "clicked → self._ext_delete" + ], + "object_name": "", + "label_vi": "Xóa" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\cowork_tab.py", + "controls": [ + { + "var": "self.skills_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('cowork.skills_btn')", + "line": 30, + "signals": [ + "clicked → self._open_skills_manager" + ], + "object_name": "", + "label_vi": "Skills" + }, + { + "var": "self._new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('cowork.new_chat')", + "line": 34, + "signals": [ + "clicked → self.new_session" + ], + "object_name": "", + "label_vi": "Cuộc trò chuyện mới" + }, + { + "var": "self.folder_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('cowork.pick_folder_btn')", + "line": 48, + "signals": [ + "clicked → self._pick_output_folder" + ], + "object_name": "", + "label_vi": "Thư mục Local…" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\dashboard_tab.py", + "controls": [ + { + "var": "self.chart_prev_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.chart_prev')", + "line": 59, + "signals": [ + "clicked → self._chart_prev" + ], + "object_name": "", + "label_vi": "Kỳ trước" + }, + { + "var": "self.chart_next_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.chart_next')", + "line": 67, + "signals": [ + "clicked → self._chart_next" + ], + "object_name": "", + "label_vi": "Kỳ sau" + }, + { + "var": "self.gran_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 71, + "signals": [ + "currentIndexChanged → self._on_gran_changed" + ], + "object_name": "" + }, + { + "var": "self.metric_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 75, + "signals": [ + "currentIndexChanged → self._refresh_chart" + ], + "object_name": "" + }, + { + "var": "self.currency_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('dashboard.currency_tooltip')", + "line": 84, + "signals": [ + "currentIndexChanged → self._on_currency_changed" + ], + "object_name": "", + "label_vi": "Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trong config)." + }, + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 91, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "" + }, + { + "var": "self.ai_analyze_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.ai_analyze_btn')", + "line": 145, + "signals": [ + "clicked → self._ai_analyze" + ], + "object_name": "", + "label_vi": "AI phân tích" + }, + { + "var": "self.apply_strategy_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('dashboard.strategy_btn')", + "line": 150, + "signals": [ + "clicked → self._apply_saving_strategy" + ], + "object_name": "", + "label_vi": "Áp dụng chiến lược tiết kiệm" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\ext_connector_dialog.py", + "controls": [ + { + "var": "self.preset_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 35, + "signals": [ + "currentIndexChanged → self._apply_preset" + ], + "object_name": "" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('name', '')", + "line": 43, + "signals": [], + "object_name": "" + }, + { + "var": "self.mode_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 47, + "signals": [ + "currentIndexChanged → lambda i: self.stack.setCurrentIndex(self.mode_combo.currentData() != 'mcp_stdio')" + ], + "object_name": "" + }, + { + "var": "self.command_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('command', '')", + "line": 59, + "signals": [], + "object_name": "" + }, + { + "var": "self.args_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "' '.join(connector.get('args', []) or [])", + "line": 62, + "signals": [], + "object_name": "" + }, + { + "var": "self.base_url_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('base_url', '')", + "line": 69, + "signals": [], + "object_name": "" + }, + { + "var": "self.api_key_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('api_key', '')", + "line": 72, + "signals": [], + "object_name": "" + }, + { + "var": "self.auth_header_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('auth_header', 'Authorization')", + "line": 75, + "signals": [], + "object_name": "" + }, + { + "var": "self.auth_scheme_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "connector.get('auth_scheme', 'Bearer')", + "line": 77, + "signals": [], + "object_name": "" + }, + { + "var": "test_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('ext.test_btn')", + "line": 90, + "signals": [ + "clicked → self._test_connection" + ], + "object_name": "", + "label_vi": "Kiểm tra kết nối" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 103, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\file_edit_dialog.py", + "controls": [ + { + "var": "self.path_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "str(p)", + "line": 66, + "signals": [], + "object_name": "" + }, + { + "var": "self.browse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.browse_tooltip')", + "line": 68, + "signals": [ + "clicked → self._browse" + ], + "object_name": "", + "label_vi": "Mở file khác…" + }, + { + "var": "self.reload_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.reload_tooltip')", + "line": 73, + "signals": [ + "clicked → self._reload" + ], + "object_name": "", + "label_vi": "Tải lại từ đĩa" + }, + { + "var": "self.editor", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('fileedit.pick_hint')", + "line": 84, + "signals": [], + "object_name": "", + "label_vi": "Mở một file để xem hoặc chỉnh sửa." + }, + { + "var": "self.instruction_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('fileedit.instruction_placeholder')", + "line": 94, + "signals": [ + "returnPressed → self._ai_edit" + ], + "object_name": "", + "label_vi": "Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch sang tiếng Anh')…" + }, + { + "var": "self.ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.ai_btn')", + "line": 97, + "signals": [ + "clicked → self._ai_edit" + ], + "object_name": "", + "label_vi": "Sửa bằng AI" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.save_btn')", + "line": 106, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.close_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('fileedit.close_btn')", + "line": 110, + "signals": [ + "clicked → self.reject" + ], + "object_name": "", + "label_vi": "Đóng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\flow_dialog.py", + "controls": [ + { + "var": "self.tabs", + "type": "QTabWidget", + "kind": "dải tab", + "label": "", + "line": 50, + "signals": [ + "currentChanged → lambda _i: self._reload_agent_picker()", + "currentChanged → lambda _i: self._reload_skill_combo()" + ], + "object_name": "" + }, + { + "var": "self.tpl_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 57, + "signals": [ + "activated → self._load_selected_template" + ], + "object_name": "" + }, + { + "var": "tpl_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.load_builtin')", + "line": 60, + "signals": [ + "clicked → self._load_builtin" + ], + "object_name": "", + "label_vi": "Tải template Req→Demo" + }, + { + "var": "new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.new')", + "line": 63, + "signals": [ + "clicked → self._new_flow" + ], + "object_name": "", + "label_vi": "Mới" + }, + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.delete_template')", + "line": 66, + "signals": [ + "clicked → self._delete_template" + ], + "object_name": "", + "label_vi": "Xóa template" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "flow.name", + "line": 75, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "flow.description", + "line": 76, + "signals": [], + "object_name": "" + }, + { + "var": "up", + "type": "QPushButton", + "kind": "nút", + "label": "'↑'", + "line": 91, + "signals": [ + "clicked → lambda: self._move(-1)" + ], + "object_name": "" + }, + { + "var": "down", + "type": "QPushButton", + "kind": "nút", + "label": "'↓'", + "line": 93, + "signals": [ + "clicked → lambda: self._move(1)" + ], + "object_name": "" + }, + { + "var": "rm", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.remove_stage')", + "line": 95, + "signals": [ + "clicked → self._remove_step" + ], + "object_name": "", + "label_vi": "Xóa bước" + }, + { + "var": "self.step_name", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.name", + "line": 107, + "signals": [], + "object_name": "" + }, + { + "var": "self.step_hint", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "step.hint", + "line": 108, + "signals": [], + "object_name": "" + }, + { + "var": "self.step_agent", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 115, + "signals": [ + "currentIndexChanged → self._reload_step_models" + ], + "object_name": "" + }, + { + "var": "self._gen_prompt_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.gen_task_from_hint')", + "line": 129, + "signals": [ + "clicked → self._gen_prompt" + ], + "object_name": "", + "label_vi": "Tạo task từ gợi ý" + }, + { + "var": "self.attach_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.attach_files')", + "line": 144, + "signals": [ + "clicked → self._pick_attachments" + ], + "object_name": "", + "label_vi": "Đính kèm file…" + }, + { + "var": "self.compact_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('flow.compact_after_run')", + "line": 150, + "signals": [], + "object_name": "", + "label_vi": "Compact after run (nén sau khi chạy)" + }, + { + "var": "self.verify_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('flow.self_verify')", + "line": 153, + "signals": [], + "object_name": "", + "label_vi": "Self-verify trước khi bàn giao" + }, + { + "var": "self.retries_spin", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('flow.review_retries_tooltip')", + "line": 156, + "signals": [], + "object_name": "", + "label_vi": "Nếu tự kiểm tra thấy chưa hoàn thành, chạy lại bước này tối đa số lần này (0 = tắt)" + }, + { + "var": "self.sub_name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('flow.subagent_name_placeholder')", + "line": 173, + "signals": [], + "object_name": "", + "label_vi": "Tên (vd backend)" + }, + { + "var": "self.sub_prompt_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('flow.subagent_task_placeholder')", + "line": 175, + "signals": [], + "object_name": "", + "label_vi": "Nhiệm vụ của sub-agent này (tùy chọn — bỏ trống thì dùng task của bước)" + }, + { + "var": "sub_add", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.subagent_add')", + "line": 177, + "signals": [ + "clicked → self._add_subagent" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "sub_remove", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.subagent_remove')", + "line": 180, + "signals": [ + "clicked → self._remove_subagent" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "agent_add", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.subagent_add_from_agent')", + "line": 194, + "signals": [ + "clicked → self._add_subagent_from_agent" + ], + "object_name": "", + "label_vi": "Thêm từ Agent" + }, + { + "var": "add_step", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.add_stage')", + "line": 207, + "signals": [ + "clicked → self._add_step" + ], + "object_name": "primary", + "label_vi": "Thêm bước" + }, + { + "var": "upd_step", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.update_stage')", + "line": 211, + "signals": [ + "clicked → self._update_step" + ], + "object_name": "", + "label_vi": "Cập nhật bước" + }, + { + "var": "save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.save_template')", + "line": 231, + "signals": [ + "clicked → self._save_template" + ], + "object_name": "", + "label_vi": "Lưu làm template" + }, + { + "var": "run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.run')", + "line": 234, + "signals": [ + "clicked → self._run" + ], + "object_name": "primary", + "label_vi": "Chạy flow" + }, + { + "var": "close_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('flow.close')", + "line": 238, + "signals": [ + "clicked → self.reject" + ], + "object_name": "", + "label_vi": "Đóng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\folder_tab.py", + "controls": [ + { + "var": "self.path_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self._root", + "line": 265, + "signals": [], + "object_name": "" + }, + { + "var": "self._open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.open_folder')", + "line": 267, + "signals": [ + "clicked → self._pick_root" + ], + "object_name": "primary", + "label_vi": "Mở thư mục" + }, + { + "var": "self.mode_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.edit') if not self.mode_btn.isChecked() else tr('folder.preview')", + "line": 299, + "signals": [ + "clicked → self._toggle_edit_mode" + ], + "object_name": "" + }, + { + "var": "self.ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_edit')", + "line": 304, + "signals": [ + "clicked → self._toggle_ai_panel" + ], + "object_name": "", + "label_vi": "AI Edit" + }, + { + "var": "self.save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.save')", + "line": 309, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu" + }, + { + "var": "self.ext_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.open_external')", + "line": 315, + "signals": [ + "clicked → self._open_external" + ], + "object_name": "", + "label_vi": "Mở bằng app ngoài" + }, + { + "var": "table", + "type": "QTableWidget", + "kind": "bảng", + "label": "len(rows)", + "line": 534, + "signals": [], + "object_name": "" + }, + { + "var": "self.ai_input", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('folder.ai_placeholder')", + "line": 736, + "signals": [ + "returnPressed → self._ai_send" + ], + "object_name": "", + "label_vi": "Mô tả chỉnh sửa… (vd: thêm xử lý lỗi)" + }, + { + "var": "self.ai_send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_send')", + "line": 740, + "signals": [ + "clicked → self._ai_send" + ], + "object_name": "primary", + "label_vi": "Gửi" + }, + { + "var": "self._ai_discard_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_discard')", + "line": 752, + "signals": [ + "clicked → self._ai_discard" + ], + "object_name": "", + "label_vi": "Hủy" + }, + { + "var": "self._ai_apply_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('folder.ai_apply')", + "line": 755, + "signals": [ + "clicked → self._ai_apply" + ], + "object_name": "primary", + "label_vi": "Áp dụng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\help_agent_widget.py", + "controls": [ + { + "var": "self.edge_tab", + "type": "QPushButton", + "kind": "nút", + "label": "self", + "line": 169, + "signals": [ + "clicked → self._show_launcher" + ], + "object_name": "helpEdgeTab" + }, + { + "var": "self.collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "self", + "line": 178, + "signals": [ + "clicked → self._hide_to_edge" + ], + "object_name": "helpCollapseBtn" + }, + { + "var": "self.min_btn", + "type": "QPushButton", + "kind": "nút", + "label": "header", + "line": 214, + "signals": [ + "clicked → self._collapse" + ], + "object_name": "helpMinBtn" + }, + { + "var": "self.input", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "row", + "line": 236, + "signals": [ + "returnPressed → self._send" + ], + "object_name": "helpInput" + }, + { + "var": "self.send_btn", + "type": "QPushButton", + "kind": "nút", + "label": "row", + "line": 241, + "signals": [ + "clicked → self._send" + ], + "object_name": "helpSendBtn" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\icons.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\icons_admin_tab.py", + "controls": [ + { + "var": "self.search", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('icons_admin.search')", + "line": 43, + "signals": [ + "textChanged → self._reload_builtin" + ], + "object_name": "", + "label_vi": "Tìm icon có sẵn…" + }, + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('icons_admin.add')", + "line": 58, + "signals": [ + "clicked → self._add_icon" + ], + "object_name": "", + "label_vi": "Thêm tệp SVG" + }, + { + "var": "self.paste_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('icons_admin.paste')", + "line": 60, + "signals": [ + "clicked → self._add_from_svg_text" + ], + "object_name": "", + "label_vi": "Dán SVG" + }, + { + "var": "self.del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('icons_admin.delete')", + "line": 62, + "signals": [ + "clicked → self._delete_icon" + ], + "object_name": "", + "label_vi": "Xóa tùy chỉnh" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\libreoffice_view.py", + "controls": [ + { + "var": "self._open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('libreoffice.open_btn')", + "line": 97, + "signals": [ + "clicked → self._open_external" + ], + "object_name": "primary", + "label_vi": "Mở bằng LibreOffice" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\login_dialog.py", + "controls": [ + { + "var": "exit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.exit_btn')", + "line": 87, + "signals": [ + "clicked → self.reject" + ], + "object_name": "", + "label_vi": "Thoát" + }, + { + "var": "self.bs_dir_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self.ctx.config.shared_dir", + "line": 121, + "signals": [], + "object_name": "" + }, + { + "var": "browse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.browse')", + "line": 122, + "signals": [ + "clicked → self._bs_browse" + ], + "object_name": "", + "label_vi": "Chọn…" + }, + { + "var": "create_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.create_admin')", + "line": 137, + "signals": [ + "clicked → self._bs_create_admin" + ], + "object_name": "primary", + "label_vi": "Tạo tài khoản Admin" + }, + { + "var": "self.code_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "cached_code", + "line": 186, + "signals": [], + "object_name": "" + }, + { + "var": "self.department_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self.ctx.config.auth.get('last_department', '')", + "line": 199, + "signals": [], + "object_name": "" + }, + { + "var": "login_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.login_btn')", + "line": 209, + "signals": [ + "clicked → lambda: self._do_login(shared_dir)" + ], + "object_name": "primary", + "label_vi": "Đăng nhập" + }, + { + "var": "offline_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.offline_btn', role=role)", + "line": 256, + "signals": [ + "clicked → lambda: self._finish_login(Account(username=username, role=role, code=''))" + ], + "object_name": "primary" + }, + { + "var": "retry_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('login.retry_btn')", + "line": 263, + "signals": [ + "clicked → self._retry" + ], + "object_name": "", + "label_vi": "Thử lại" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\mcp_servers_dialog.py", + "controls": [ + { + "var": "self.name", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "server.get('name', '')", + "line": 25, + "signals": [], + "object_name": "" + }, + { + "var": "self.command", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "server.get('command', '')", + "line": 30, + "signals": [], + "object_name": "" + }, + { + "var": "self.args", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "' '.join(server.get('args', []) or [])", + "line": 35, + "signals": [], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 39, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\monitoring_tab.py", + "controls": [ + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.refresh')", + "line": 149, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "", + "label_vi": "Làm mới" + }, + { + "var": "self.status_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 175, + "signals": [], + "object_name": "" + }, + { + "var": "search", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('monitoring.filter_placeholder')", + "line": 347, + "signals": [ + "textChanged → table.apply_filter" + ], + "object_name": "", + "label_vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…" + }, + { + "var": "ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.ai_filter_btn')", + "line": 350, + "signals": [ + "clicked → lambda: self._ai_filter(search, ai_btn)" + ], + "object_name": "", + "label_vi": "AI" + }, + { + "var": "self.ov_pricing_ccy", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 486, + "signals": [ + "currentIndexChanged → self._reload_pricing_table" + ], + "object_name": "" + }, + { + "var": "self.ov_price_import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_import')", + "line": 496, + "signals": [ + "clicked → self._import_pricing" + ], + "object_name": "", + "label_vi": "Nhập" + }, + { + "var": "self.ov_price_export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_export')", + "line": 498, + "signals": [ + "clicked → self._export_pricing" + ], + "object_name": "", + "label_vi": "Mẫu" + }, + { + "var": "self.ov_price_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_add')", + "line": 500, + "signals": [ + "clicked → self._add_pricing_row" + ], + "object_name": "", + "label_vi": "Thêm" + }, + { + "var": "self.ov_price_link_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_autolink')", + "line": 502, + "signals": [ + "clicked → self._autolink_pricing" + ], + "object_name": "", + "label_vi": "Tự lấy" + }, + { + "var": "self.ov_price_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.pricing_delete')", + "line": 504, + "signals": [ + "clicked → self._delete_pricing_row" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.ov_pricing_table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 510, + "signals": [], + "object_name": "" + }, + { + "var": "self.ov_sbx_edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.overview_edit')", + "line": 547, + "signals": [ + "clicked → self._open_settings_and_refresh" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.ov_perm_edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.overview_edit')", + "line": 573, + "signals": [ + "clicked → self._open_settings_and_refresh" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "self.ov_view_all_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('monitoring.overview_view_all')", + "line": 586, + "signals": [ + "clicked → lambda: self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_page))" + ], + "object_name": "", + "label_vi": "Xem tất cả" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\osutil.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\permission_dialog.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\routing_toggle.py", + "controls": [ + { + "var": "self._combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('routing.toggle_tooltip')", + "line": 66, + "signals": [ + "currentIndexChanged → self._on_changed" + ], + "object_name": "", + "label_vi": "Tự động định tuyến model cho khung chat này.\nTắt: luôn dùng model đã chọn.\nTự động: tự chuyển sang model phù hợp nhất.\nThủ công: hỏi xác nhận trước khi chuyển." + }, + { + "var": "self._chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('routing.autorun_label')", + "line": 133, + "signals": [ + "toggled → self._on_toggled" + ], + "object_name": "", + "label_vi": "Tự chạy" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\schedule_task_tab.py", + "controls": [ + { + "var": "self.add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.add_btn')", + "line": 88, + "signals": [ + "clicked → self._add_task" + ], + "object_name": "primary", + "label_vi": "Thêm Task" + }, + { + "var": "self.ai_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.ai_btn')", + "line": 92, + "signals": [ + "clicked → self._ai_create" + ], + "object_name": "", + "label_vi": "AI tạo Task" + }, + { + "var": "self.view_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 95, + "signals": [ + "currentIndexChanged → self._on_view_changed" + ], + "object_name": "" + }, + { + "var": "self.table", + "type": "QTableWidget", + "kind": "bảng", + "label": "len(runs)", + "line": 436, + "signals": [ + "itemDoubleClicked → self._open_artifact" + ], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Close", + "line": 461, + "signals": [], + "object_name": "" + }, + { + "var": "self.workspace_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_workspace')", + "line": 523, + "signals": [], + "object_name": "", + "label_vi": "Project/workspace mà agent của task này sẽ chạy trong đó — áp dụng sandbox và hướng dẫn chung của project." + }, + { + "var": "self.desc_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('schedtask.ai_desc_ph')", + "line": 537, + "signals": [], + "object_name": "", + "label_vi": "vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo cáo markdown, sau đó Cowork soạn email draft gửi team." + }, + { + "var": "self.ai_files_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('schedtask.files_placeholder')", + "line": 544, + "signals": [], + "object_name": "", + "label_vi": "Đường dẫn tệp local, cách nhau bằng ;" + }, + { + "var": "ai_pick_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.pick_files')", + "line": 546, + "signals": [ + "clicked → self._ai_pick_files" + ], + "object_name": "", + "label_vi": "Chọn tệp…" + }, + { + "var": "self.ai_links_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('schedtask.links_placeholder')", + "line": 553, + "signals": [], + "object_name": "", + "label_vi": "https://… các link, cách nhau bằng ;" + }, + { + "var": "self.gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.ai_generate')", + "line": 557, + "signals": [ + "clicked → self._generate" + ], + "object_name": "primary", + "label_vi": "Tạo kế hoạch" + }, + { + "var": "tpl_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.export_template_btn')", + "line": 571, + "signals": [ + "clicked → self._export_template" + ], + "object_name": "", + "label_vi": "Tạo template Excel…" + }, + { + "var": "pick_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.import_pick_btn')", + "line": 576, + "signals": [ + "clicked → self._pick_import_file" + ], + "object_name": "", + "label_vi": "Chọn file…" + }, + { + "var": "self.buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Ok | QDialogButtonBox.Cancel", + "line": 592, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "tr('schedtask.menu_run')", + "line": 309, + "label_vi": "Chạy ngay" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_edit')", + "line": 310, + "label_vi": "Sửa task" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_duplicate')", + "line": 311, + "label_vi": "Nhân bản task" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_resume' if paused else 'schedtask.menu_pause')", + "line": 313 + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_logs')", + "line": 314, + "label_vi": "Xem log" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_history')", + "line": 315, + "label_vi": "Lịch sử chạy…" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_create_next')", + "line": 316, + "label_vi": "Tạo task tiếp theo từ output" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_delete')", + "line": 318, + "label_vi": "Xóa task" + }, + { + "menu": "menu", + "label": "tr('schedtask.menu_delete_selected', n=len(selected))", + "line": 348 + } + ] + }, + { + "file": "ui\\settings_dialog.py", + "controls": [ + { + "var": "self.tray_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.tray_keep')", + "line": 56, + "signals": [], + "object_name": "", + "label_vi": "Giữ chạy nền trong khay hệ thống khi đóng cửa sổ" + }, + { + "var": "self.notify_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.tray_notify')", + "line": 59, + "signals": [], + "object_name": "", + "label_vi": "Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗi" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 70, + "signals": [ + "currentIndexChanged → self._on_provider_edit_changed" + ], + "object_name": "" + }, + { + "var": "self.prov_base", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "conf.get('base_url', '')", + "line": 77, + "signals": [], + "object_name": "" + }, + { + "var": "self.sandbox_pw_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "''", + "line": 103, + "signals": [], + "object_name": "" + }, + { + "var": "self.sandbox_unlock_btn", + "type": "QPushButton", + "kind": "nút", + "label": "'Unlock'", + "line": 107, + "signals": [ + "clicked → self._sandbox_unlock" + ], + "object_name": "" + }, + { + "var": "self.sandbox_confirm", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.sandbox_confirm_commands')", + "line": 121, + "signals": [], + "object_name": "", + "label_vi": "Xác nhận trước khi Cowork chạy lệnh" + }, + { + "var": "self.sandbox_block_network", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('settings.sandbox_block_network')", + "line": 126, + "signals": [], + "object_name": "", + "label_vi": "Chặn mạng cho lệnh do agent chạy" + }, + { + "var": "self.sec_enabled", + "type": "QCheckBox", + "kind": "ô tick", + "label": "'Enable Agent Security (command validation)'", + "line": 136, + "signals": [], + "object_name": "" + }, + { + "var": "self.ai_check", + "type": "QCheckBox", + "kind": "ô tick", + "label": "'AI check commands'", + "line": 142, + "signals": [], + "object_name": "" + }, + { + "var": "self.attach_files", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.max_files_tooltip')", + "line": 178, + "signals": [], + "object_name": "", + "label_vi": "Số tệp tối đa đính kèm vào một tin nhắn." + }, + { + "var": "self.attach_tokens", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.max_per_file_tooltip')", + "line": 183, + "signals": [], + "object_name": "", + "label_vi": "Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượt sẽ bị cắt (giảm token, tránh lỗi vượt context)." + }, + { + "var": "self.struct_nodes", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.nodes_tooltip')", + "line": 194, + "signals": [], + "object_name": "", + "label_vi": "Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn). Giá trị thấp hơn giúp quét/vẽ nhanh hơn với thư mục lớn." + }, + { + "var": "self.struct_edges", + "type": "QSpinBox", + "kind": "ô số", + "label": "tr('settings.edges_tooltip')", + "line": 200, + "signals": [], + "object_name": "", + "label_vi": "Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn)." + }, + { + "var": "self.routing_judge", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "routing.get('judge_model', '')", + "line": 279, + "signals": [], + "object_name": "" + }, + { + "var": "self.routing_reassess_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('routing.settings_reassess_now')", + "line": 282, + "signals": [ + "clicked → self._routing_reassess_now" + ], + "object_name": "", + "label_vi": "Đánh giá lại ngay" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 299, + "signals": [], + "object_name": "" + }, + { + "var": "edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "value", + "line": 316, + "signals": [], + "object_name": "" + }, + { + "var": "btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.load')", + "line": 368, + "signals": [ + "clicked → lambda: self._load_models(self.provider_combo.currentData(), combo, status)" + ], + "object_name": "", + "label_vi": "Tải" + }, + { + "var": "test_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.test_connection')", + "line": 374, + "signals": [ + "clicked → lambda: self._test_connection(self.provider_combo.currentData(), status)" + ], + "object_name": "", + "label_vi": "Test kết nối" + }, + { + "var": "code_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "code", + "line": 496, + "signals": [], + "object_name": "" + }, + { + "var": "copy_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ms365_copy_code')", + "line": 503, + "signals": [ + "clicked → lambda: QGuiApplication.clipboard().setText(code)" + ], + "object_name": "", + "label_vi": "Copy mã" + }, + { + "var": "open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.ms365_open_link')", + "line": 506, + "signals": [ + "clicked → lambda: webbrowser.open(flow.get('verification_uri_complete') or url)" + ], + "object_name": "", + "label_vi": "Mở link" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\sidebar.py", + "controls": [ + { + "var": "self._collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('sidebar.collapse_tooltip')", + "line": 104, + "signals": [ + "clicked → self.collapse_requested.emit" + ], + "object_name": "", + "label_vi": "Thu gọn bảng Lịch sử" + }, + { + "var": "self.search_box", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('sidebar.search_placeholder')", + "line": 119, + "signals": [ + "textChanged → self.refresh", + "returnPressed → self.refresh" + ], + "object_name": "", + "label_vi": "Tìm theo tiêu đề hoặc nội dung…" + }, + { + "var": "self.search_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 123, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "" + }, + { + "var": "self.tree", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 132, + "signals": [ + "itemClicked → self._on_item", + "customContextMenuRequested → self._context_menu" + ], + "object_name": "" + }, + { + "var": "self._refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('sidebar.refresh')", + "line": 147, + "signals": [ + "clicked → self.refresh_requested.emit" + ], + "object_name": "", + "label_vi": "Làm mới" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "tr('sidebar.menu.unpin') if pinned else tr('sidebar.menu.pin')", + "line": 305 + }, + { + "menu": "menu", + "label": "tr('sidebar.menu.rename')", + "line": 306, + "label_vi": "Đổi tên…" + }, + { + "menu": "menu", + "label": "tr('sidebar.menu.delete')", + "line": 307, + "label_vi": "Xóa" + }, + { + "menu": "menu", + "label": "tr('sidebar.menu.delete_selected', n=len(selected))", + "line": 332 + } + ] + }, + { + "file": "ui\\skill_manager_tab.py", + "controls": [ + { + "var": "self._auto_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.auto_generate')", + "line": 47, + "signals": [ + "clicked → self._auto_generate" + ], + "object_name": "primary", + "label_vi": "Tự động tạo" + }, + { + "var": "self._template_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.from_template')", + "line": 52, + "signals": [ + "clicked → self._from_template" + ], + "object_name": "", + "label_vi": "Từ file template…" + }, + { + "var": "import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.import_btn')", + "line": 56, + "signals": [ + "clicked → self._import" + ], + "object_name": "", + "label_vi": "Nhập…" + }, + { + "var": "export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.export_btn')", + "line": 60, + "signals": [ + "clicked → self._export_md" + ], + "object_name": "", + "label_vi": "Xuất .md" + }, + { + "var": "dup_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.duplicate_btn')", + "line": 64, + "signals": [ + "clicked → self._duplicate" + ], + "object_name": "", + "label_vi": "Nhân bản" + }, + { + "var": "edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.edit_btn')", + "line": 68, + "signals": [ + "clicked → self._edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.delete_btn')", + "line": 71, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\skills_dialog.py", + "controls": [ + { + "var": "self.name", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "skill.name if skill else ''", + "line": 34, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "skill.description if skill else ''", + "line": 39, + "signals": [], + "object_name": "" + }, + { + "var": "self._gen_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.gen_from_desc')", + "line": 44, + "signals": [ + "clicked → self._gen_instructions" + ], + "object_name": "", + "label_vi": "Tạo từ mô tả" + }, + { + "var": "self.instr", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "skill.instructions if skill else ''", + "line": 50, + "signals": [], + "object_name": "" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 55, + "signals": [], + "object_name": "" + }, + { + "var": "self._auto_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.auto_generate')", + "line": 127, + "signals": [ + "clicked → self._auto_generate" + ], + "object_name": "primary", + "label_vi": "Tự động tạo" + }, + { + "var": "self._template_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.from_template')", + "line": 132, + "signals": [ + "clicked → self._from_template" + ], + "object_name": "", + "label_vi": "Từ file template…" + }, + { + "var": "import_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.import_btn')", + "line": 136, + "signals": [ + "clicked → self._import" + ], + "object_name": "", + "label_vi": "Nhập…" + }, + { + "var": "export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.export_btn')", + "line": 140, + "signals": [ + "clicked → self._export_md" + ], + "object_name": "", + "label_vi": "Xuất .md" + }, + { + "var": "dup_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.duplicate_btn')", + "line": 144, + "signals": [ + "clicked → self._duplicate" + ], + "object_name": "", + "label_vi": "Nhân bản" + }, + { + "var": "edit_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.edit_btn')", + "line": 148, + "signals": [ + "clicked → self._edit" + ], + "object_name": "", + "label_vi": "Sửa" + }, + { + "var": "del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.delete_btn')", + "line": 151, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "close_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('skills.close_btn')", + "line": 154, + "signals": [ + "clicked → self.accept" + ], + "object_name": "", + "label_vi": "Đóng" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\spline_chart.py", + "controls": [], + "menu_actions": [] + }, + { + "file": "ui\\structure_graph_view.py", + "controls": [ + { + "var": "self.path_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "str(ctx.config.cowork_output_dir())", + "line": 220, + "signals": [], + "object_name": "" + }, + { + "var": "self._pick_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.browse')", + "line": 222, + "signals": [ + "clicked → self._pick" + ], + "object_name": "primary", + "label_vi": "Browse…" + }, + { + "var": "self.project_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('structure.project_tooltip')", + "line": 226, + "signals": [ + "currentIndexChanged → self._on_project_changed" + ], + "object_name": "", + "label_vi": "Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — path chuyển sang chỉ đọc và khung hỏi-đáp Agent bên dưới sẽ theo Instructions chung của project đó (an toàn hơn, câu trả lời bám sát ngữ cảnh, giảm bịa đặt)." + }, + { + "var": "self._scan_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.scan')", + "line": 228, + "signals": [ + "clicked → self._scan" + ], + "object_name": "primary", + "label_vi": "Scan" + }, + { + "var": "self._msgs_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.msgs_tooltip')", + "line": 242, + "signals": [ + "clicked → self._toggle_messages" + ], + "object_name": "", + "label_vi": "Xem mọi message hội thoại nhóm theo ngày (dạng JSON)." + }, + { + "var": "self._export_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.export_png')", + "line": 248, + "signals": [ + "clicked → self._export" + ], + "object_name": "primary", + "label_vi": "Xuất PNG" + }, + { + "var": "self._msgs_view", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 268, + "signals": [ + "itemClicked → self._show_msg_json" + ], + "object_name": "" + }, + { + "var": "self._ag_collapse", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.collapse_agent_tooltip')", + "line": 288, + "signals": [ + "clicked → lambda: self._set_agent_collapsed(True)" + ], + "object_name": "", + "label_vi": "Thu gọn bảng Agent" + }, + { + "var": "self.ask_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "tr('structure.ask_placeholder')", + "line": 299, + "signals": [ + "returnPressed → self._ask" + ], + "object_name": "", + "label_vi": "vd. cái gì gọi hàm main? file nào định nghĩa class?" + }, + { + "var": "self._ask_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('structure.ask')", + "line": 301, + "signals": [ + "clicked → self._ask" + ], + "object_name": "primary", + "label_vi": "Hỏi" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\task_editor_dialog.py", + "controls": [ + { + "var": "self.title_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "self.task.get('title', '')", + "line": 96, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "self.task.get('description', '')", + "line": 100, + "signals": [], + "object_name": "" + }, + { + "var": "self.gen_desc_btn", + "type": "QPushButton", + "kind": "nút", + "label": "''", + "line": 102, + "signals": [ + "clicked → self._gen_prompt_from_description" + ], + "object_name": "" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 135, + "signals": [ + "currentIndexChanged → self._refresh_model_combo" + ], + "object_name": "" + }, + { + "var": "self.load_models_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('schedtask.load_models_tooltip')", + "line": 147, + "signals": [ + "clicked → self._load_live_models" + ], + "object_name": "", + "label_vi": "Tải danh sách model của provider này" + }, + { + "var": "self.run_kind_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_run_kind')", + "line": 170, + "signals": [ + "currentIndexChanged → self._on_run_kind_changed" + ], + "object_name": "", + "label_vi": "AI agent = chạy một agent Cowork với model đã chọn. Co4E flow = chạy cả một flow node-graph đã lưu, tuần tự, trong sandbox." + }, + { + "var": "self.flow_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_flow')", + "line": 176, + "signals": [], + "object_name": "", + "label_vi": "Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn)." + }, + { + "var": "self.task_mode_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.hint_task_mode')", + "line": 188, + "signals": [ + "currentIndexChanged → self._on_task_mode_changed" + ], + "object_name": "", + "label_vi": "Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjob lặp theo lịch (ngày/tuần/tháng/cron). Chuyển sang Tự động sẽ hiện các tùy chọn lặp lại." + }, + { + "var": "self.sched_enabled", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.sched_enable')", + "line": 217, + "signals": [], + "object_name": "", + "label_vi": "Bật lịch chạy" + }, + { + "var": "self.repeat_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 232, + "signals": [ + "currentIndexChanged → self._on_repeat_changed" + ], + "object_name": "" + }, + { + "var": "self.cron_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "sched.get('cron_expression') or ''", + "line": 238, + "signals": [], + "object_name": "" + }, + { + "var": "self.cron_sample", + "type": "QComboBox", + "kind": "droplist", + "label": "tr('schedtask.cron_sample_tooltip')", + "line": 242, + "signals": [ + "currentIndexChanged → self._on_cron_sample" + ], + "object_name": "", + "label_vi": "Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron." + }, + { + "var": "self.working_days_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.workdays_only')", + "line": 258, + "signals": [], + "object_name": "", + "label_vi": "Chỉ ngày làm việc (bỏ T7/CN)" + }, + { + "var": "self.skip_holidays_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.skip_holidays')", + "line": 260, + "signals": [], + "object_name": "", + "label_vi": "Bỏ qua ngày nghỉ lễ" + }, + { + "var": "self.holiday_country_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "sched.get('holiday_country', 'VN') or 'VN'", + "line": 262, + "signals": [], + "object_name": "" + }, + { + "var": "self.notify_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 274, + "signals": [ + "currentIndexChanged → self._on_notify_changed" + ], + "object_name": "" + }, + { + "var": "self.notify_email_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "ex_sched.get('notify_email', '') or ''", + "line": 280, + "signals": [], + "object_name": "" + }, + { + "var": "self.manual_text", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "inp.get('manual_text') or ''", + "line": 313, + "signals": [], + "object_name": "" + }, + { + "var": "self.files_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 324, + "signals": [ + "clicked → self._add_files" + ], + "object_name": "" + }, + { + "var": "self.files_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 328, + "signals": [ + "clicked → lambda: self._remove_selected(self.files_list)" + ], + "object_name": "" + }, + { + "var": "self.links_add_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 349, + "signals": [ + "clicked → self._add_link" + ], + "object_name": "" + }, + { + "var": "self.links_del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "", + "line": 353, + "signals": [ + "clicked → lambda: self._remove_selected(self.links_list)" + ], + "object_name": "" + }, + { + "var": "self.next_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 376, + "signals": [ + "currentIndexChanged → self._check_chain" + ], + "object_name": "" + }, + { + "var": "self.pass_output_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.pass_output')", + "line": 385, + "signals": [], + "object_name": "", + "label_vi": "Dùng output task này làm input task sau" + }, + { + "var": "self.approval_chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "tr('schedtask.requires_approval')", + "line": 421, + "signals": [], + "object_name": "", + "label_vi": "Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngay)" + }, + { + "var": "buttons", + "type": "QDialogButtonBox", + "kind": "nút hộp thoại", + "label": "QDialogButtonBox.Save | QDialogButtonBox.Cancel", + "line": 430, + "signals": [], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\terminal_panel.py", + "controls": [ + { + "var": "self._toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('terminal.expand_tooltip') if self._collapsed else tr('terminal.collapse_tooltip')", + "line": 85, + "signals": [ + "clicked → self.toggle" + ], + "object_name": "" + }, + { + "var": "self.output", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "", + "line": 105, + "signals": [], + "object_name": "termOutput" + }, + { + "var": "self._run_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('terminal.run')", + "line": 130, + "signals": [ + "clicked → self._run_current" + ], + "object_name": "primary", + "label_vi": "Chạy" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\tools_admin_tab.py", + "controls": [ + { + "var": "chk", + "type": "QCheckBox", + "kind": "ô tick", + "label": "", + "line": 32, + "signals": [ + "toggled → on_toggle" + ], + "object_name": "" + }, + { + "var": "self.table", + "type": "QTableWidget", + "kind": "bảng", + "label": "0", + "line": 57, + "signals": [], + "object_name": "" + }, + { + "var": "self.test_internet_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('settings.test_internet')", + "line": 72, + "signals": [ + "clicked → self._test_internet" + ], + "object_name": "", + "label_vi": "Kiểm tra Internet" + }, + { + "var": "self.refresh_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('tools_admin.refresh')", + "line": 80, + "signals": [ + "clicked → self.refresh" + ], + "object_name": "", + "label_vi": "Làm mới" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\widgets.py", + "controls": [ + { + "var": "self.header", + "type": "QPushButton", + "kind": "nút", + "label": "f'{arrow} {self._title} ({self._count})'", + "line": 254, + "signals": [ + "toggled → self._toggle", + "toggled → self._toggle" + ], + "object_name": "" + }, + { + "var": "self.list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 261, + "signals": [ + "itemClicked → self._emit" + ], + "object_name": "" + } + ], + "menu_actions": [] + }, + { + "file": "ui\\workspace_tab.py", + "controls": [ + { + "var": "self._proj_collapse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.collapse_projects_tooltip')", + "line": 90, + "signals": [ + "clicked → lambda: self._set_projects_collapsed(True)" + ], + "object_name": "", + "label_vi": "Thu gọn danh sách project" + }, + { + "var": "self.project_list", + "type": "QListWidget", + "kind": "danh sách", + "label": "", + "line": 97, + "signals": [ + "currentItemChanged → self._on_select" + ], + "object_name": "" + }, + { + "var": "self._new_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.new_project')", + "line": 101, + "signals": [ + "clicked → self._create" + ], + "object_name": "primary", + "label_vi": "Project mới" + }, + { + "var": "self._del_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.delete')", + "line": 105, + "signals": [ + "clicked → self._delete" + ], + "object_name": "", + "label_vi": "Xóa" + }, + { + "var": "self.tabs", + "type": "QTabWidget", + "kind": "dải tab", + "label": "", + "line": 129, + "signals": [ + "currentChanged → self._on_tab_changed" + ], + "object_name": "" + }, + { + "var": "self.name_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "project.name", + "line": 193, + "signals": [], + "object_name": "" + }, + { + "var": "self.desc_edit", + "type": "QLineEdit", + "kind": "ô nhập", + "label": "project.description", + "line": 194, + "signals": [], + "object_name": "" + }, + { + "var": "self.instr_edit", + "type": "QPlainTextEdit", + "kind": "ô nhập nhiều dòng", + "label": "tr('workspace.instructions_placeholder')", + "line": 203, + "signals": [], + "object_name": "", + "label_vi": "vd: \"Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; luôn theo quy tắc đặt tên …\"" + }, + { + "var": "self._browse_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.browse')", + "line": 211, + "signals": [ + "clicked → self._pick_folder" + ], + "object_name": "", + "label_vi": "Đổi thư mục…" + }, + { + "var": "self._open_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.open_folder')", + "line": 214, + "signals": [ + "clicked → self._open_workspace" + ], + "object_name": "", + "label_vi": "Mở thư mục" + }, + { + "var": "self._save_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('workspace.save')", + "line": 223, + "signals": [ + "clicked → self._save" + ], + "object_name": "primary", + "label_vi": "Lưu project" + } + ], + "menu_actions": [] + }, + { + "file": "app.py", + "controls": [ + { + "var": "self.nav", + "type": "QTreeWidget", + "kind": "cây", + "label": "", + "line": 175, + "signals": [ + "currentItemChanged → lambda cur, _prev: self._navigate(cur)", + "itemClicked → self._on_nav_click" + ], + "object_name": "navrail" + }, + { + "var": "self._nav_toggle_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('app.nav.menu_label')", + "line": 218, + "signals": [ + "clicked → self._toggle_nav" + ], + "object_name": "navMenuBtn", + "label_vi": "MENU" + }, + { + "var": "self.provider_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 559, + "signals": [ + "currentIndexChanged → self._on_provider_changed" + ], + "object_name": "" + }, + { + "var": "self.language_combo", + "type": "QComboBox", + "kind": "droplist", + "label": "", + "line": 568, + "signals": [ + "currentIndexChanged → self._on_language_changed" + ], + "object_name": "" + }, + { + "var": "self.settings_btn", + "type": "QPushButton", + "kind": "nút", + "label": "tr('app.settings')", + "line": 586, + "signals": [ + "clicked → self._open_settings" + ], + "object_name": "", + "label_vi": "Cài đặt" + } + ], + "menu_actions": [ + { + "menu": "menu", + "label": "self._tray_open_act", + "line": 329 + }, + { + "menu": "menu", + "label": "self._tray_quit_act", + "line": 330 + }, + { + "menu": "menu", + "label": "_icon(icon_name)", + "line": 625 + } + ] + } +] \ No newline at end of file diff --git a/docs/screens/dashboard-dark.png b/docs/screens/dashboard-dark.png new file mode 100644 index 0000000..c58bdb2 Binary files /dev/null and b/docs/screens/dashboard-dark.png differ diff --git a/docs/screens/dashboard-light.png b/docs/screens/dashboard-light.png new file mode 100644 index 0000000..768f385 Binary files /dev/null and b/docs/screens/dashboard-light.png differ diff --git a/docs/screens/dialog-agent-edit-dark.png b/docs/screens/dialog-agent-edit-dark.png new file mode 100644 index 0000000..e78624e Binary files /dev/null and b/docs/screens/dialog-agent-edit-dark.png differ diff --git a/docs/screens/dialog-agent-edit-light.png b/docs/screens/dialog-agent-edit-light.png new file mode 100644 index 0000000..d0280a3 Binary files /dev/null and b/docs/screens/dialog-agent-edit-light.png differ diff --git a/docs/screens/dialog-co4e-agent-dark.png b/docs/screens/dialog-co4e-agent-dark.png new file mode 100644 index 0000000..0156a6e Binary files /dev/null and b/docs/screens/dialog-co4e-agent-dark.png differ diff --git a/docs/screens/dialog-co4e-agent-light.png b/docs/screens/dialog-co4e-agent-light.png new file mode 100644 index 0000000..728ec3c Binary files /dev/null and b/docs/screens/dialog-co4e-agent-light.png differ diff --git a/docs/screens/dialog-ext-connector-dark.png b/docs/screens/dialog-ext-connector-dark.png new file mode 100644 index 0000000..7dcb0f2 Binary files /dev/null and b/docs/screens/dialog-ext-connector-dark.png differ diff --git a/docs/screens/dialog-ext-connector-light.png b/docs/screens/dialog-ext-connector-light.png new file mode 100644 index 0000000..9d765fa Binary files /dev/null and b/docs/screens/dialog-ext-connector-light.png differ diff --git a/docs/screens/dialog-file-edit-dark.png b/docs/screens/dialog-file-edit-dark.png new file mode 100644 index 0000000..cbf5910 Binary files /dev/null and b/docs/screens/dialog-file-edit-dark.png differ diff --git a/docs/screens/dialog-file-edit-light.png b/docs/screens/dialog-file-edit-light.png new file mode 100644 index 0000000..09779f1 Binary files /dev/null and b/docs/screens/dialog-file-edit-light.png differ diff --git a/docs/screens/dialog-login-dark.png b/docs/screens/dialog-login-dark.png new file mode 100644 index 0000000..4753366 Binary files /dev/null and b/docs/screens/dialog-login-dark.png differ diff --git a/docs/screens/dialog-login-light.png b/docs/screens/dialog-login-light.png new file mode 100644 index 0000000..fbc9a25 Binary files /dev/null and b/docs/screens/dialog-login-light.png differ diff --git a/docs/screens/dialog-permission-dark.png b/docs/screens/dialog-permission-dark.png new file mode 100644 index 0000000..5094b38 Binary files /dev/null and b/docs/screens/dialog-permission-dark.png differ diff --git a/docs/screens/dialog-permission-light.png b/docs/screens/dialog-permission-light.png new file mode 100644 index 0000000..8a79320 Binary files /dev/null and b/docs/screens/dialog-permission-light.png differ diff --git a/docs/screens/dialog-settings-dark.png b/docs/screens/dialog-settings-dark.png new file mode 100644 index 0000000..4617b94 Binary files /dev/null and b/docs/screens/dialog-settings-dark.png differ diff --git a/docs/screens/dialog-settings-light.png b/docs/screens/dialog-settings-light.png new file mode 100644 index 0000000..1a18749 Binary files /dev/null and b/docs/screens/dialog-settings-light.png differ diff --git a/docs/screens/dialog-skill-edit-dark.png b/docs/screens/dialog-skill-edit-dark.png new file mode 100644 index 0000000..d5e08e3 Binary files /dev/null and b/docs/screens/dialog-skill-edit-dark.png differ diff --git a/docs/screens/dialog-skill-edit-light.png b/docs/screens/dialog-skill-edit-light.png new file mode 100644 index 0000000..3439fff Binary files /dev/null and b/docs/screens/dialog-skill-edit-light.png differ diff --git a/docs/screens/dialog-skills-dark.png b/docs/screens/dialog-skills-dark.png new file mode 100644 index 0000000..3794709 Binary files /dev/null and b/docs/screens/dialog-skills-dark.png differ diff --git a/docs/screens/dialog-skills-light.png b/docs/screens/dialog-skills-light.png new file mode 100644 index 0000000..1519b84 Binary files /dev/null and b/docs/screens/dialog-skills-light.png differ diff --git a/docs/screens/dialog-task-editor-dark.png b/docs/screens/dialog-task-editor-dark.png new file mode 100644 index 0000000..b61ad96 Binary files /dev/null and b/docs/screens/dialog-task-editor-dark.png differ diff --git a/docs/screens/dialog-task-editor-light.png b/docs/screens/dialog-task-editor-light.png new file mode 100644 index 0000000..adc98e3 Binary files /dev/null and b/docs/screens/dialog-task-editor-light.png differ diff --git a/docs/screens/manifest.json b/docs/screens/manifest.json new file mode 100644 index 0000000..092a864 --- /dev/null +++ b/docs/screens/manifest.json @@ -0,0 +1,542 @@ +[ + { + "slug": "dashboard", + "title": "Dashboard", + "theme": "dark", + "note": "ui/dashboard_tab.py:35", + "file": "screens/dashboard-dark.png", + "error": "", + "nav": "Dashboard", + "nav_expected": "Dashboard" + }, + { + "slug": "schedule-kanban", + "title": "Schedule Task — Kanban", + "theme": "dark", + "note": "ui/schedule_task_tab.py:70", + "file": "screens/schedule-kanban-dark.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "schedule-calendar", + "title": "Schedule Task — Calendar", + "theme": "dark", + "note": "ui/calendar_view.py:88", + "file": "screens/schedule-calendar-dark.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "workspace-project", + "title": "Workspace ▸ Project", + "theme": "dark", + "note": "ui/workspace_tab.py:188", + "file": "screens/workspace-project-dark.png", + "error": "", + "nav": "Workspace", + "nav_expected": "Workspace" + }, + { + "slug": "workspace-cowork", + "title": "Workspace ▸ Cowork", + "theme": "dark", + "note": "ui/cowork_tab.py:21", + "file": "screens/workspace-cowork-dark.png", + "error": "", + "nav": "Cowork", + "nav_expected": "Cowork" + }, + { + "slug": "workspace-co4e", + "title": "Workspace ▸ Co4E", + "theme": "dark", + "note": "ui/co4e_tab.py:228", + "file": "screens/workspace-co4e-dark.png", + "error": "", + "nav": "Co4E", + "nav_expected": "Co4E" + }, + { + "slug": "workspace-folder", + "title": "Workspace ▸ Folder", + "theme": "dark", + "note": "ui/folder_tab.py:238", + "file": "screens/workspace-folder-dark.png", + "error": "", + "nav": "Thư mục", + "nav_expected": "Thư mục" + }, + { + "slug": "workspace-graphrag", + "title": "Workspace ▸ GraphRAG", + "theme": "dark", + "note": "ui/structure_graph_view.py:188", + "file": "screens/workspace-graphrag-dark.png", + "error": "", + "nav": "GraphRAG", + "nav_expected": "GraphRAG" + }, + { + "slug": "monitoring-tổng-quan", + "title": "Monitoring ▸ Tổng quan", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-tổng-quan-dark.png", + "error": "", + "nav": "Tổng quan", + "nav_expected": "Tổng quan" + }, + { + "slug": "monitoring-sự-kiện-bảo-mật", + "title": "Monitoring ▸ Sự kiện bảo mật", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-sự-kiện-bảo-mật-dark.png", + "error": "", + "nav": "Sự kiện bảo mật", + "nav_expected": "Sự kiện bảo mật" + }, + { + "slug": "monitoring-lịch-sử-gọi-mcp", + "title": "Monitoring ▸ Lịch sử gọi MCP", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-lịch-sử-gọi-mcp-dark.png", + "error": "", + "nav": "Lịch sử gọi MCP", + "nav_expected": "Lịch sử gọi MCP" + }, + { + "slug": "monitoring-nhật-ký-hành-động", + "title": "Monitoring ▸ Nhật ký hành động", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-nhật-ký-hành-động-dark.png", + "error": "", + "nav": "Nhật ký hành động", + "nav_expected": "Nhật ký hành động" + }, + { + "slug": "monitoring-trạng-thái-agent", + "title": "Monitoring ▸ Trạng thái Agent", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-trạng-thái-agent-dark.png", + "error": "", + "nav": "Trạng thái Agent", + "nav_expected": "Trạng thái Agent" + }, + { + "slug": "monitoring-agents-admin", + "title": "Monitoring ▸ Agents Admin", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-agents-admin-dark.png", + "error": "", + "nav": "Agents Admin", + "nav_expected": "Agents Admin" + }, + { + "slug": "monitoring-công-cụ", + "title": "Monitoring ▸ Công cụ", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-công-cụ-dark.png", + "error": "", + "nav": "Công cụ", + "nav_expected": "Công cụ" + }, + { + "slug": "monitoring-icon", + "title": "Monitoring ▸ Icon", + "theme": "dark", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-icon-dark.png", + "error": "", + "nav": "Icon", + "nav_expected": "Icon" + }, + { + "slug": "dialog-settings", + "title": "Settings", + "theme": "dark", + "note": "ui/settings_dialog.py:26", + "file": "screens/dialog-settings-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-task-editor", + "title": "Task Editor", + "theme": "dark", + "note": "ui/task_editor_dialog.py:55", + "file": "screens/dialog-task-editor-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skills", + "title": "Skills manager", + "theme": "dark", + "note": "ui/skills_dialog.py:108", + "file": "screens/dialog-skills-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skill-edit", + "title": "Skill editor", + "theme": "dark", + "note": "ui/skills_dialog.py:23", + "file": "screens/dialog-skill-edit-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-file-edit", + "title": "File view & AI edit", + "theme": "dark", + "note": "ui/file_edit_dialog.py:50", + "file": "screens/dialog-file-edit-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-co4e-agent", + "title": "Co4E agent editor", + "theme": "dark", + "note": "ui/co4e_agent_dialog.py:23", + "file": "screens/dialog-co4e-agent-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-ext-connector", + "title": "External connector", + "theme": "dark", + "note": "ui/ext_connector_dialog.py:23", + "file": "screens/dialog-ext-connector-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-permission", + "title": "Permission request", + "theme": "dark", + "note": "ui/permission_dialog.py:13", + "file": "screens/dialog-permission-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-agent-edit", + "title": "Admin agent editor", + "theme": "dark", + "note": "ui/agents_admin_tab.py:35", + "file": "screens/dialog-agent-edit-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-login", + "title": "Login (dead screen — not wired)", + "theme": "dark", + "note": "ui/login_dialog.py:57", + "file": "screens/dialog-login-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "overlay-help-panel", + "title": "Help dock — expanded panel", + "theme": "dark", + "note": "ui/help_agent_widget.py:79", + "file": "screens/overlay-help-panel-dark.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dashboard", + "title": "Dashboard", + "theme": "light", + "note": "ui/dashboard_tab.py:35", + "file": "screens/dashboard-light.png", + "error": "", + "nav": "Dashboard", + "nav_expected": "Dashboard" + }, + { + "slug": "schedule-kanban", + "title": "Schedule Task — Kanban", + "theme": "light", + "note": "ui/schedule_task_tab.py:70", + "file": "screens/schedule-kanban-light.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "schedule-calendar", + "title": "Schedule Task — Calendar", + "theme": "light", + "note": "ui/calendar_view.py:88", + "file": "screens/schedule-calendar-light.png", + "error": "", + "nav": "Schedule Task", + "nav_expected": "Schedule Task" + }, + { + "slug": "workspace-project", + "title": "Workspace ▸ Project", + "theme": "light", + "note": "ui/workspace_tab.py:188", + "file": "screens/workspace-project-light.png", + "error": "", + "nav": "Workspace", + "nav_expected": "Workspace" + }, + { + "slug": "workspace-cowork", + "title": "Workspace ▸ Cowork", + "theme": "light", + "note": "ui/cowork_tab.py:21", + "file": "screens/workspace-cowork-light.png", + "error": "", + "nav": "Cowork", + "nav_expected": "Cowork" + }, + { + "slug": "workspace-co4e", + "title": "Workspace ▸ Co4E", + "theme": "light", + "note": "ui/co4e_tab.py:228", + "file": "screens/workspace-co4e-light.png", + "error": "", + "nav": "Co4E", + "nav_expected": "Co4E" + }, + { + "slug": "workspace-folder", + "title": "Workspace ▸ Folder", + "theme": "light", + "note": "ui/folder_tab.py:238", + "file": "screens/workspace-folder-light.png", + "error": "", + "nav": "Thư mục", + "nav_expected": "Thư mục" + }, + { + "slug": "workspace-graphrag", + "title": "Workspace ▸ GraphRAG", + "theme": "light", + "note": "ui/structure_graph_view.py:188", + "file": "screens/workspace-graphrag-light.png", + "error": "", + "nav": "GraphRAG", + "nav_expected": "GraphRAG" + }, + { + "slug": "monitoring-tổng-quan", + "title": "Monitoring ▸ Tổng quan", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-tổng-quan-light.png", + "error": "", + "nav": "Tổng quan", + "nav_expected": "Tổng quan" + }, + { + "slug": "monitoring-sự-kiện-bảo-mật", + "title": "Monitoring ▸ Sự kiện bảo mật", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-sự-kiện-bảo-mật-light.png", + "error": "", + "nav": "Sự kiện bảo mật", + "nav_expected": "Sự kiện bảo mật" + }, + { + "slug": "monitoring-lịch-sử-gọi-mcp", + "title": "Monitoring ▸ Lịch sử gọi MCP", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-lịch-sử-gọi-mcp-light.png", + "error": "", + "nav": "Lịch sử gọi MCP", + "nav_expected": "Lịch sử gọi MCP" + }, + { + "slug": "monitoring-nhật-ký-hành-động", + "title": "Monitoring ▸ Nhật ký hành động", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-nhật-ký-hành-động-light.png", + "error": "", + "nav": "Nhật ký hành động", + "nav_expected": "Nhật ký hành động" + }, + { + "slug": "monitoring-trạng-thái-agent", + "title": "Monitoring ▸ Trạng thái Agent", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-trạng-thái-agent-light.png", + "error": "", + "nav": "Trạng thái Agent", + "nav_expected": "Trạng thái Agent" + }, + { + "slug": "monitoring-agents-admin", + "title": "Monitoring ▸ Agents Admin", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-agents-admin-light.png", + "error": "", + "nav": "Agents Admin", + "nav_expected": "Agents Admin" + }, + { + "slug": "monitoring-công-cụ", + "title": "Monitoring ▸ Công cụ", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-công-cụ-light.png", + "error": "", + "nav": "Công cụ", + "nav_expected": "Công cụ" + }, + { + "slug": "monitoring-icon", + "title": "Monitoring ▸ Icon", + "theme": "light", + "note": "ui/monitoring_tab.py:132", + "file": "screens/monitoring-icon-light.png", + "error": "", + "nav": "Icon", + "nav_expected": "Icon" + }, + { + "slug": "dialog-settings", + "title": "Settings", + "theme": "light", + "note": "ui/settings_dialog.py:26", + "file": "screens/dialog-settings-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-task-editor", + "title": "Task Editor", + "theme": "light", + "note": "ui/task_editor_dialog.py:55", + "file": "screens/dialog-task-editor-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skills", + "title": "Skills manager", + "theme": "light", + "note": "ui/skills_dialog.py:108", + "file": "screens/dialog-skills-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-skill-edit", + "title": "Skill editor", + "theme": "light", + "note": "ui/skills_dialog.py:23", + "file": "screens/dialog-skill-edit-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-file-edit", + "title": "File view & AI edit", + "theme": "light", + "note": "ui/file_edit_dialog.py:50", + "file": "screens/dialog-file-edit-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-co4e-agent", + "title": "Co4E agent editor", + "theme": "light", + "note": "ui/co4e_agent_dialog.py:23", + "file": "screens/dialog-co4e-agent-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-ext-connector", + "title": "External connector", + "theme": "light", + "note": "ui/ext_connector_dialog.py:23", + "file": "screens/dialog-ext-connector-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-permission", + "title": "Permission request", + "theme": "light", + "note": "ui/permission_dialog.py:13", + "file": "screens/dialog-permission-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-agent-edit", + "title": "Admin agent editor", + "theme": "light", + "note": "ui/agents_admin_tab.py:35", + "file": "screens/dialog-agent-edit-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "dialog-login", + "title": "Login (dead screen — not wired)", + "theme": "light", + "note": "ui/login_dialog.py:57", + "file": "screens/dialog-login-light.png", + "error": "", + "nav": "", + "nav_expected": "" + }, + { + "slug": "overlay-help-panel", + "title": "Help dock — expanded panel", + "theme": "light", + "note": "ui/help_agent_widget.py:79", + "file": "screens/overlay-help-panel-light.png", + "error": "", + "nav": "", + "nav_expected": "" + } +] \ No newline at end of file diff --git a/docs/screens/monitoring-agents-admin-dark.png b/docs/screens/monitoring-agents-admin-dark.png new file mode 100644 index 0000000..ccddefd Binary files /dev/null and b/docs/screens/monitoring-agents-admin-dark.png differ diff --git a/docs/screens/monitoring-agents-admin-light.png b/docs/screens/monitoring-agents-admin-light.png new file mode 100644 index 0000000..b631d64 Binary files /dev/null and b/docs/screens/monitoring-agents-admin-light.png differ diff --git a/docs/screens/monitoring-công-cụ-dark.png b/docs/screens/monitoring-công-cụ-dark.png new file mode 100644 index 0000000..30d502d Binary files /dev/null and b/docs/screens/monitoring-công-cụ-dark.png differ diff --git a/docs/screens/monitoring-công-cụ-light.png b/docs/screens/monitoring-công-cụ-light.png new file mode 100644 index 0000000..f9dd41a Binary files /dev/null and b/docs/screens/monitoring-công-cụ-light.png differ diff --git a/docs/screens/monitoring-icon-dark.png b/docs/screens/monitoring-icon-dark.png new file mode 100644 index 0000000..6747f32 Binary files /dev/null and b/docs/screens/monitoring-icon-dark.png differ diff --git a/docs/screens/monitoring-icon-light.png b/docs/screens/monitoring-icon-light.png new file mode 100644 index 0000000..f596c1b Binary files /dev/null and b/docs/screens/monitoring-icon-light.png differ diff --git a/docs/screens/monitoring-lịch-sử-gọi-mcp-dark.png b/docs/screens/monitoring-lịch-sử-gọi-mcp-dark.png new file mode 100644 index 0000000..c5f6f94 Binary files /dev/null and b/docs/screens/monitoring-lịch-sử-gọi-mcp-dark.png differ diff --git a/docs/screens/monitoring-lịch-sử-gọi-mcp-light.png b/docs/screens/monitoring-lịch-sử-gọi-mcp-light.png new file mode 100644 index 0000000..218bed4 Binary files /dev/null and b/docs/screens/monitoring-lịch-sử-gọi-mcp-light.png differ diff --git a/docs/screens/monitoring-nhật-ký-hành-động-dark.png b/docs/screens/monitoring-nhật-ký-hành-động-dark.png new file mode 100644 index 0000000..f0b3120 Binary files /dev/null and b/docs/screens/monitoring-nhật-ký-hành-động-dark.png differ diff --git a/docs/screens/monitoring-nhật-ký-hành-động-light.png b/docs/screens/monitoring-nhật-ký-hành-động-light.png new file mode 100644 index 0000000..f028031 Binary files /dev/null and b/docs/screens/monitoring-nhật-ký-hành-động-light.png differ diff --git a/docs/screens/monitoring-sự-kiện-bảo-mật-dark.png b/docs/screens/monitoring-sự-kiện-bảo-mật-dark.png new file mode 100644 index 0000000..728db2a Binary files /dev/null and b/docs/screens/monitoring-sự-kiện-bảo-mật-dark.png differ diff --git a/docs/screens/monitoring-sự-kiện-bảo-mật-light.png b/docs/screens/monitoring-sự-kiện-bảo-mật-light.png new file mode 100644 index 0000000..8316dc0 Binary files /dev/null and b/docs/screens/monitoring-sự-kiện-bảo-mật-light.png differ diff --git a/docs/screens/monitoring-trạng-thái-agent-dark.png b/docs/screens/monitoring-trạng-thái-agent-dark.png new file mode 100644 index 0000000..6bef289 Binary files /dev/null and b/docs/screens/monitoring-trạng-thái-agent-dark.png differ diff --git a/docs/screens/monitoring-trạng-thái-agent-light.png b/docs/screens/monitoring-trạng-thái-agent-light.png new file mode 100644 index 0000000..378ea20 Binary files /dev/null and b/docs/screens/monitoring-trạng-thái-agent-light.png differ diff --git a/docs/screens/monitoring-tổng-quan-dark.png b/docs/screens/monitoring-tổng-quan-dark.png new file mode 100644 index 0000000..1941d25 Binary files /dev/null and b/docs/screens/monitoring-tổng-quan-dark.png differ diff --git a/docs/screens/monitoring-tổng-quan-light.png b/docs/screens/monitoring-tổng-quan-light.png new file mode 100644 index 0000000..aedb87a Binary files /dev/null and b/docs/screens/monitoring-tổng-quan-light.png differ diff --git a/docs/screens/overlay-help-panel-dark.png b/docs/screens/overlay-help-panel-dark.png new file mode 100644 index 0000000..a489b02 Binary files /dev/null and b/docs/screens/overlay-help-panel-dark.png differ diff --git a/docs/screens/overlay-help-panel-light.png b/docs/screens/overlay-help-panel-light.png new file mode 100644 index 0000000..ee7aacd Binary files /dev/null and b/docs/screens/overlay-help-panel-light.png differ diff --git a/docs/screens/schedule-calendar-dark.png b/docs/screens/schedule-calendar-dark.png new file mode 100644 index 0000000..2f28682 Binary files /dev/null and b/docs/screens/schedule-calendar-dark.png differ diff --git a/docs/screens/schedule-calendar-light.png b/docs/screens/schedule-calendar-light.png new file mode 100644 index 0000000..5996950 Binary files /dev/null and b/docs/screens/schedule-calendar-light.png differ diff --git a/docs/screens/schedule-kanban-dark.png b/docs/screens/schedule-kanban-dark.png new file mode 100644 index 0000000..35dc6b0 Binary files /dev/null and b/docs/screens/schedule-kanban-dark.png differ diff --git a/docs/screens/schedule-kanban-light.png b/docs/screens/schedule-kanban-light.png new file mode 100644 index 0000000..07335cc Binary files /dev/null and b/docs/screens/schedule-kanban-light.png differ diff --git a/docs/screens/workspace-co4e-dark.png b/docs/screens/workspace-co4e-dark.png new file mode 100644 index 0000000..7045f1c Binary files /dev/null and b/docs/screens/workspace-co4e-dark.png differ diff --git a/docs/screens/workspace-co4e-light.png b/docs/screens/workspace-co4e-light.png new file mode 100644 index 0000000..0d1126c Binary files /dev/null and b/docs/screens/workspace-co4e-light.png differ diff --git a/docs/screens/workspace-cowork-dark.png b/docs/screens/workspace-cowork-dark.png new file mode 100644 index 0000000..9a19911 Binary files /dev/null and b/docs/screens/workspace-cowork-dark.png differ diff --git a/docs/screens/workspace-cowork-light.png b/docs/screens/workspace-cowork-light.png new file mode 100644 index 0000000..7fd6f6f Binary files /dev/null and b/docs/screens/workspace-cowork-light.png differ diff --git a/docs/screens/workspace-folder-dark.png b/docs/screens/workspace-folder-dark.png new file mode 100644 index 0000000..e96be02 Binary files /dev/null and b/docs/screens/workspace-folder-dark.png differ diff --git a/docs/screens/workspace-folder-light.png b/docs/screens/workspace-folder-light.png new file mode 100644 index 0000000..1f50781 Binary files /dev/null and b/docs/screens/workspace-folder-light.png differ diff --git a/docs/screens/workspace-graphrag-dark.png b/docs/screens/workspace-graphrag-dark.png new file mode 100644 index 0000000..fac834f Binary files /dev/null and b/docs/screens/workspace-graphrag-dark.png differ diff --git a/docs/screens/workspace-graphrag-light.png b/docs/screens/workspace-graphrag-light.png new file mode 100644 index 0000000..6f3d340 Binary files /dev/null and b/docs/screens/workspace-graphrag-light.png differ diff --git a/docs/screens/workspace-project-dark.png b/docs/screens/workspace-project-dark.png new file mode 100644 index 0000000..99262dc Binary files /dev/null and b/docs/screens/workspace-project-dark.png differ diff --git a/docs/screens/workspace-project-light.png b/docs/screens/workspace-project-light.png new file mode 100644 index 0000000..a09e4bb Binary files /dev/null and b/docs/screens/workspace-project-light.png differ diff --git a/docs/ui-audit.html b/docs/ui-audit.html new file mode 100644 index 0000000..5a15692 --- /dev/null +++ b/docs/ui-audit.html @@ -0,0 +1,598 @@ + + +CoworkLocal — Audit UI/UX + +
+
+

CoworkLocal — Audit UI/UX

+

Hiện trạng 27 màn hình · đề xuất sắp xếp lại theo UI/UX kiểu Claude · +bảng màu Visual Studio Code · dựng lúc 21:26 15/08/2026

+
Ràng buộc: chỉ sắp xếp lại, không xoá/thêm chức năng. +Hai chỗ lệch được đánh dấu ở Phần 1.
+
Ảnh chụp: render offscreen trên bản sao dữ liệu +(scheduler tắt, hash dữ liệu thật trước/sau giống hệt). Nạp dữ liệu mẫu: 3 project · +6 chat · 10 task · 3 workflow · 45 ngày token. Ảnh đã chỉnh menu sáng đúng mục — +xem lỗi #9.
+
+ +
Mục lục +

Phần 1 — Điều hướng · Phần 2 — Luồng người dùng · +Phần 3 — 27 màn hình · Phần 4 — Màn chết

+
  1. Dashboard
  2. Schedule Task — Kanban
  3. Schedule Task — Calendar
  4. Workspace ▸ Project
  5. Workspace ▸ Cowork
  6. Workspace ▸ Co4E
  7. Workspace ▸ Folder
  8. Workspace ▸ GraphRAG
  9. Monitoring ▸ Tổng quan
  10. Monitoring ▸ Sự kiện bảo mật
  11. Monitoring ▸ Lịch sử gọi MCP
  12. Monitoring ▸ Nhật ký hành động
  13. Monitoring ▸ Trạng thái Agent
  14. Monitoring ▸ Agents Admin
  15. Monitoring ▸ Công cụ
  16. Monitoring ▸ Icon
  17. Settings
  18. Task Editor
  19. Skills manager
  20. Skill editor
  21. File view & AI edit
  22. Co4E agent editor
  23. External connector
  24. Permission request
  25. Admin agent editor
  26. Login (dead screen — not wired)
  27. Help dock — expanded panel
+ +

Phần 1 — Điều hướng

+
+
Hiện tại
Dashboard
+Schedule Task
+Workspace  ▼          ← nhánh accordion, tab strip bên trong BỊ ẨN
+   Project
+   Cowork             ← TỰ ẨN khi chưa chọn project
+   Co4E
+   Folder
+   GraphRAG           ← TỰ ẨN khi chưa chọn project
+Monitoring ▼          ← nhánh accordion, tab strip BỊ ẨN
+   Tổng quan
+   Sự kiện bảo mật
+   Lịch sử gọi MCP
+   Nhật ký hành động
+   Trạng thái Agent
+   Agents Admin
+   Công cụ
+   Icon
+
Đề xuất
[ + Đoạn chat mới ]     ← hành động chính, trên cùng
+──────────────
+Project                 ← màn đầu, giữ nguyên
+Cowork                  ← luôn hiện (mờ đi nếu chưa chọn project)
+Co4E
+Folder
+GraphRAG                ← luôn hiện (mờ đi nếu chưa chọn project)
+Schedule Task
+──────────────
+RECENTS                 ← History dời từ pane giữa lên đây
+  · thread gần nhất…
+──────────────  (ghim đáy — nhóm phụ trợ)
+Dashboard               ← vẫn 1 cú nhấp như cũ
+Monitoring              ← BỎ ẨN dải tab, đủ 8 mục nằm ngang trong trang:
+   [Tổng quan] [Sự kiện bảo mật] [Lịch sử gọi MCP]
+   [Nhật ký hành động] [Trạng thái Agent]
+   [Agents Admin] [Công cụ] [Icon]
+👤 local · Provider ▾   ← gom Provider/Language/Theme/Settings
+
+

Chín điểm đã xác minh trong code

+
Vấn đềChi tiết
Accordion 2 cấp, không phẳngapp.py:170 ghi là \“Claude-style\” nhưng là QTreeWidget accordion. Claude dùng danh sách phẳng.
Mục tự biến mấtworkspace_tab.py:485 ẩn Cowork và GraphRAG khi chưa chọn project.
Tab strip bị ẩnhide_tab_bar() ẩn dải tab có sẵn → menu là đường duy nhất.
History chỉ có ở tab Coworkworkspace_tab.py:169. Điểm làm tốt phải giữ: lịch sử đã gom theo project — lưu trong <project>/.cowork_history (config.py:590), hiển thị gom nhóm ở sidebar.py:193.
Monitoring gom 5 việc rời rạcChi phí · log bảo mật · quản trị agent · cấu hình tool · thư viện icon.
Dashboard trùng Monitoring ▸ Tổng quanCùng bộ StatCard + BudgetCard.
Top bar giữ thiết lậpProvider/Ngôn ngữ/Giao diện ở topbar, tách khỏi Cài đặt.
Header không đổi theo mànMọi sub-tab đều hiện \“Workspace — Projects\”.
LỖI: bấm mục con Workspace, menu nhảy về mục cha_goto gọi refresh() (app.py:726) → subtabs_changed vô điều kiện (workspace_tab.py:497) → takeChildren() (app.py:691) huỷ mục vừa bấm.
Đo được: 5/5 mục Workspace mất highlight, 0/8 mục Monitoring bị.
+ +

Khung ứng dụng — thanh trên cùng & thanh menu

+

Không thuộc màn nào nên liệt kê riêng.

+
Kiểm kê control — 8 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—câylambda cur, _prev: self._navigate(cur); self._on_nav_clickapp.py:175giữ nguyên tại chỗ
MENUnútself._toggle_navapp.py:218Giữ — nút MENU gập sidebar (150↔54px)
—droplistself._on_provider_changedapp.py:559→ menu tài khoản ở đáy sidebar
—droplistself._on_language_changedapp.py:568→ menu tài khoản ở đáy sidebar
Cài đặtnútself._open_settingsapp.py:586→ menu tài khoản ở đáy sidebar
self._tray_open_actmenu chuột phải—app.py:329giữ nguyên tại chỗ
self._tray_quit_actmenu chuột phải—app.py:330giữ nguyên tại chỗ
_icon(icon_namemenu chuột phải—app.py:625giữ nguyên tại chỗ
+ +

Các nút thu gọn / mở rộng — giữ nguyên toàn bộ

+

App có 11 chỗ gập được. Thiết kế mới giữ đủ cả 11.

+
+
Menu mở rộng
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Nội dung
+
Menu đã gập (54px, chỉ còn icon)
+
+
▣
▤
◫
⌥
◈
▦
◔
◕
👤
Nội dung
+
+ +
Chỗ gập đượcCách dùngNguồnThiết kế mới
Thanh menu chínhMENU ‹ ở đầu thanh — gập còn dải icon (150px → 54px)app.py:409giữ
Pane Projectchevron ‹ trên đầu danh sách projectworkspace_tab.py:368giữ
Pane Lịch sửchevron trên đầu History, gập thành dải mỏngsidebar.py:167 · workspace_tab.py:247giữ
Pane Tệp trong chatchevron › — gập panel Files bên phải khung chatchat_panel.py:709giữ
Panel Hỏi đáp GraphRAGchevron › — gập panel agent bên phải đồ thịstructure_graph_view.py:615giữ
Panel cấu hình bước Co4Enút gập panel phải của canvasco4e_tab.py:767giữ
Panel Tin nhắn Co4Egập khung log dưới canvas — mặc định đang gậpco4e_tab.py:894giữ
Terminal trong Thư mụcbấm thanh tiêu đề để mở/gập — mặc định đang gậpterminal_panel.py:156giữ
Panel AI sửa tệpnút ✨ bật/tắt panel — mặc định đang ẩnfolder_tab.py:777giữ
Đồ thị ⇄ Tin nhắn (GraphRAG)nút đổi nội dung pane tráistructure_graph_view.py:416giữ — đổi thành cặp tab
Khối kết quả công cụ trong chatbấm tiêu đề để mở/gập output dàichat_view.py:253giữ
+ +

Từng màn hình đi đâu sau khi sửa

+

Không màn nào bị bỏ. Giám sát (Dashboard + 8 màn Monitoring) ở nhóm +phụ trợ đáy sidebar, mở ra thấy đủ 8 tab.

+ +
Màn hìnhHiện tại ở đâuSau khi sửa ở đâuChức năng
Màn hình làm việc
Workspace ▸ ProjectMenu ▸ Workspace (mở nhánh) ▸ ProjectSidebar ▸ Project — lên cấp 1, bớt 1 lần mở nhánhgiữ nguyên
Workspace ▸ CoworkMenu ▸ Workspace ▸ Cowork — biến mất nếu chưa chọn projectSidebar ▸ Cowork — luôn thấy, mờ khi chưa chọngiữ nguyên
Workspace ▸ Co4EMenu ▸ Workspace ▸ Co4ESidebar ▸ Co4Egiữ nguyên
Workspace ▸ Folder (“Thư mục”)Menu ▸ Workspace ▸ Thư mụcSidebar ▸ Foldergiữ nguyên
Workspace ▸ GraphRAGMenu ▸ Workspace ▸ GraphRAG — biến mất nếu chưa chọn projectSidebar ▸ GraphRAG — luôn thấygiữ nguyên
Schedule Task — KanbanMenu ▸ Schedule TaskSidebar ▸ Schedule Task ▸ tab Kanbangiữ nguyên
Schedule Task — LịchMenu ▸ Schedule Task ▸ combo đổi sang “Lịch”Sidebar ▸ Schedule Task ▸ tab Lịch — combo thành tab, dễ thấy hơngiữ nguyên
Giám sát & vận hành — phần bạn hỏi
DashboardMenu ▸ Dashboard (cấp 1)Sidebar ▸ vùng đáy — vẫn 1 cú nhấpgiữ nguyên
Monitoring ▸ Tổng quanMenu ▸ Monitoring (mở nhánh) ▸ Tổng quanSidebar ▸ Monitoring ▸ tab Tổng quangiữ nguyên
Monitoring ▸ Sự kiện bảo mậtMenu ▸ Monitoring ▸ Sự kiện bảo mậtSidebar ▸ Monitoring ▸ tab Sự kiện bảo mậtgiữ nguyên
Monitoring ▸ Lịch sử gọi MCPMenu ▸ Monitoring ▸ Lịch sử gọi MCPSidebar ▸ Monitoring ▸ tab Lịch sử gọi MCPgiữ nguyên
Monitoring ▸ Nhật ký hành độngMenu ▸ Monitoring ▸ Nhật ký hành độngSidebar ▸ Monitoring ▸ tab Nhật ký hành độnggiữ nguyên
Monitoring ▸ Trạng thái AgentMenu ▸ Monitoring ▸ Trạng thái AgentSidebar ▸ Monitoring ▸ tab Trạng thái Agentgiữ nguyên
Monitoring ▸ Agents AdminMenu ▸ Monitoring ▸ Agents AdminSidebar ▸ Monitoring ▸ tab Agents Admingiữ nguyên
Monitoring ▸ Công cụMenu ▸ Monitoring ▸ Công cụ ▸ tab con Tool | ConnectorSidebar ▸ Monitoring ▸ tab Công cụ ▸ Tool | Connectorgiữ nguyên
Monitoring ▸ IconMenu ▸ Monitoring ▸ IconSidebar ▸ Monitoring ▸ tab Icongiữ nguyên
Thành phần bị dời chỗ
History (lịch sử chat)Pane giữa, chỉ ở tab Cowork. Đã gom theo project.Sidebar ▸ RECENTS — vẫn gom theo project + “Tất cả project…”giữ, dễ tới hơn
Bộ chọn projectPane trái, chỉ ở tab ProjectThanh chọn đầu trang, dùng chung mọi màngiữ, dễ tới hơn
Provider · Ngôn ngữ · Giao diệnThanh trên cùng (topbar)Menu tài khoản ở đáy sidebar — gom cùng chỗ với Cài đặtgiữ nguyên
Nút Cài đặtThanh trên cùngMenu tài khoản ở đáy sidebargiữ nguyên
Hộp thoại & lớp phủ
11 hộp thoạiMở từ nút trên các màn tương ứngKhông đổi — vẫn mở từ đúng những nút đógiữ nguyên
Robot trợ giúp · Terminal · ComposerLớp phủ / panel thu gọnKhông đổigiữ nguyên
+
Dashboard/Monitoring xuống đáy nhưng vẫn là mục cấp 1, vẫn +một cú nhấp. Sidebar chia theo tần suất: việc hằng ngày ở trên, quản trị ở dưới.
+ +

Toàn bộ nhóm tab / lane / chế độ trong app

+

Đầy đủ, kể cả nhóm không hiện ra màn hình.

+ +
NhómSLCác mụcNhìn thấy?Nguồn
Thanh menu trái4 mụcDashboard · Schedule Task · Workspace · Monitoringcóapp.py:154
Workspace ▸ mục con5 mụcProject · Cowork · Co4E · Folder · GraphRAGkhông — hide_tab_bar(), và Cowork/GraphRAG còn tự ẩn khi chưa chọn projectworkspace_tab.py:43
Monitoring ▸ mục con8 mụcTổng quan · Sự kiện bảo mật · Lịch sử gọi MCP · Nhật ký hành động · Trạng thái Agent · Agents Admin · Công cụ · Iconkhông — hide_tab_bar()monitoring_tab.py:215
Công cụ ▸ tab con2 tabTool · Connectorcó — màn duy nhất còn hiện dải tabtools_admin_tab.py:91
Schedule ▸ chế độ xem2 chế độKanban · Lịchlà combo, không phải tabschedule_task_tab.py:171
Kanban ▸ lane trạng thái7 lanebacklog · scheduled · running · waiting_input · done · failed · pausedcắt ở mép phải, phải cuộn ngangtasks.py:24
Co4E ▸ sidebar3 tab iconWorkflows · Agents · Skillschỉ có icon, tên nằm trong tooltipco4e_tab.py:426
Co4E ▸ dải tab flow1 + NFlow Status (ghim) + mỗi workflow đang mở một tabcó, kiểu trình duyệtco4e_tab.py:556
Folder ▸ trình xem5 trangtrống · mã nguồn · HTML · tài liệu · ảnh (+ bảng tính)tự đổi theo đuôi tệp, không có tabfolder_tab.py:322
GraphRAG ▸ khung trái2 trangĐồ thị · Tin nhắnlà nút bấm, không phải tabstructure_graph_view.py:217
Dialog Tạo task bằng AI2 tabSinh bằng AI · Nhập từ Excelcóschedule_task_tab.py:530
Dialog Kết nối ngoài2 chế độMCP (stdio) · REST APIlà combo đổi trangext_connector_dialog.py:23
Dialog Đăng nhập (màn chết)3 trangKhởi tạo lần đầu · Đăng nhập · Dự phòng offlinekhông tới đượclogin_dialog.py:74
Dialog Flow Builder (màn chết)3 tabFlow · Agents · Skillskhông tới đượcflow_dialog.py:247
+
14 nhóm tab/lane, chỉ 4 nhóm hiện ra màn hình. +Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới được.
+
Hai chỗ lệch — cần bạn quyết: +(1) Cowork/GraphRAG: biến mất → hiện nhưng mờ. +(2) Bỏ ẩn tab strip Monitoring (khôi phục thứ đã có).
+ +

Phần 2 — Luồng người dùng

+
LuồngHiện tạiSau khi sửa
Khởi động → màn đầupython -m cowork_local → MainWindow → Workspace ▸ Project
Không có bước đăng nhập (LoginDialog bị bỏ qua)
Giữ nguyên đích đến. Sidebar phẳng nên Project là mục đầu, không còn nằm dưới nhánh Workspace.
Tạo project → chatNav ▸ Workspace (mở nhánh) → Project → “+” → điền form → Lưu → chọn project → nav ▸ Cowork (mục vừa mới xuất hiện) → gõSidebar ▸ Project → “+” → Lưu → sidebar ▸ Cowork (luôn nhìn thấy) → gõ.
Bớt 1 bước mở nhánh, và menu không đổi hình giữa chừng.
Co4E: tạo → chạy → xem runNav ▸ Workspace ▸ Co4E → “+” trên dải tab → kéo agent từ sidebar → chọn node → sửa ở panel phải → Lưu → Chạy → bấm tab Flow Status để xemSidebar ▸ Co4E → “+ Workflow” trong danh sách trái → kéo → sửa phải → Lưu → Chạy.
Trạng thái run là một mục trong danh sách trái, không phải tab riêng.
Tạo & chạy scheduled taskNav ▸ Schedule → “+ Task” → form 5 group → Lưu → kéo thẻ vào lane Running (chạy ngay, không hỏi)Sidebar ▸ Schedule Task → “+ Task” → form 3 tab → Lưu → kéo vào Running (lane có viền cảnh báo).
Duyệt tệp → AI sửaNav ▸ Workspace ▸ Folder → chọn tệp → bấm ✨ mở panel AI → gõ lệnh → xem plan → xem diff → ApplySidebar ▸ Folder → chọn tệp → ✨ mở lớp phủ AI → gõ → plan → diff → Apply.
Luồng giữ nguyên; panel không còn chiếm chỗ cố định.
+ +

Truy vết chi tiết: “Đoạn chat mới”

+ +
Khía cạnhGiao diện cũGiao diện mớiCó đồng bộ không
Số lối vào1 — nút “Cuộc trò chuyện mới” trên toolbar Cowork (cowork_tab.py:34)2 — nút sidebar + nút toolbar cũ giữ nguyênTôi vẽ thêm nút sidebar mà chưa nói gì về nút cũ. Giữ cả hai (bỏ nút cũ là xoá chức năng), cùng gọi một hàm.
Thấy được khi nàoChỉ khi đang ở tab Cowork — mà tab này tự ẩn khi chưa chọn projectLuôn thấy trên sidebarMới dễ tới hơn. Chưa chọn project thì nút mờ đi.
Chat mới thuộc project nàoProject đang mở, ngầm định — không hiển thị ở đâuBộ chọn project ngay trên nút, trong sidebarCùng hành vi — vẫn là project đang mở (ctx.active_project_id), nhưng nay nhìn thấy và đổi được tại chỗ.
Đổi project trước khi tạoPhải rời Cowork → về tab Project → chọn dòng trong danh sách → quay lại Cowork → bấm nút. 4 bước.Bấm droplist ngay trên nút → chọn → bấm nút. 2 bước, không rời màn.Ít bước hơn, không thêm chức năng — vẫn là chọn project rồi tạo chat.
Bấm từ màn khácKhông xảy ra được — nút chỉ có trên CoworkChuyển sang Cowork rồi tạo chat mớiHành vi mới, cần thiết vì nút giờ ở mọi màn.
Việc thực sự làmnew_session() (chat_panel.py:1653): xoá messages · sinh session_id mới · dọn view, composer, plan, tệp vào/ra · turn đang chạy vẫn chạy nềnGiữ y nguyênKhông đổi.
Lưu chat cũTự lưu; History refresh qua history_changedGiữ y nguyên — RECENTS refreshKhông đổi.
+
Phát hiện: sidebar.py:68 khai báo tín hiệu +new_chat và workspace_tab.py:241 đã nối nó vào +_on_sidebar_new — nhưng không nơi nào phát tín hiệu này +(grep new_chat.emit → rỗng). Tức pane Lịch sử vốn được thiết kế để có nút +“chat mới” nhưng nút đó chưa bao giờ được thêm. Đề xuất đưa nút lên sidebar chính là +hoàn thiện ý định sẵn có trong code, không phải thêm mới.
+ +

Phần 3 — Từng màn hình

+
+
1. Dashboardui/dashboard_tab.py:35
+
+

Token đã tiêu và chi phí quy ra tiền, theo kỳ.

+
Hiện tại
Dashboard
+

Header: kỳ · granularity · metric · tiền tệ · 6 thẻ: Total · In · Out · Cache · Cost · Ngân sách · Biểu đồ: spline + đường so sánh kỳ trước · Thói quen: top task tốn token

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Dashboard
◀
08/03 – 08/09
▶
Theo tuần ▾
Chi phí ▾
USD ▾
⟳
$0.31Tổng chi phí · 57 lượt
395.4KTổng token
292.8KInput
102.7KOutput
108.9KCache
Tốn nhiều nhất: Dựng slide trình bày — 105.4K (26%)
+
Kiểm kê control — 10 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Kỳ trướcnútself._chart_prevui\dashboard_tab.py:59giữ nguyên tại chỗ
Kỳ saunútself._chart_nextui\dashboard_tab.py:67giữ nguyên tại chỗ
—droplistself._on_gran_changedui\dashboard_tab.py:71giữ nguyên tại chỗ
—droplistself._refresh_chartui\dashboard_tab.py:75giữ nguyên tại chỗ
Tiền tệ hiển thị (tỉ giá USD→VND/JPY cố định, sửa được trongdroplistself._on_currency_changedui\dashboard_tab.py:84giữ nguyên tại chỗ
nútself.refreshui\dashboard_tab.py:91→ lên sidebar cùng RECENTS
AI phân tíchnútself._ai_analyzeui\dashboard_tab.py:145giữ nguyên tại chỗ
Áp dụng chiến lược tiết kiệmnútself._apply_saving_strategyui\dashboard_tab.py:150giữ nguyên tại chỗ
f'{arrow} {self._title} ({self._count}nútself._toggle; self._toggleui\widgets.py:254giữ nguyên tại chỗ
—danh sáchself._emitui\widgets.py:261giữ nguyên tại chỗ
+
+
Vấn đề
  • Tám control trên một hàng header.
  • Trùng Monitoring ▸ Tổng quan: cùng StatCard + BudgetCard.
  • Sáu thẻ số bằng nhau — không thấy đâu là chỉ số chính.
+
Thay đổi
  • Header tách 2 hàng: thời gian / bộ lọc.
  • Nâng Chi phí làm thẻ chính, 4 thẻ còn lại phụ.
+
+
2. Schedule Task — Kanbanui/schedule_task_tab.py:70
+
+

Kanban các tác vụ hẹn giờ. Bộ lập lịch chạy nền dù màn này đóng.

+
Hiện tại
Schedule Task — Kanban
+

Lane: một cột mỗi trạng thái · Thẻ: kéo đổi lane · double-click sửa · chuột phải: Chạy ngay / Lịch sử

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Kanban
Lịch
+ Task
✨ AI tạo
BACKLOG (2)
[AI] Xuất DS khách hàng B2BChưa đặt lịch
Rà soát bảo mật trước releasehigh
ĐÃ LÊN LỊCH (2)
Quét lại chỉ mục ISO08-11 14:32
Báo cáo doanh thu 08:0008-09 14:32
ĐANG CHẠY (1) ⚠
Đồng bộ heartbeat trạm sạc08-08 · high
CHỜ DUYỆT (1)
Chờ kế toán duyệt số liệu T7Chưa đặt lịch
XONG (2)
Sao lưu CSDL hằng đêm08-07 · critical
[AI] Slide tổng kết Q3Thành công
LỖI (1)
Kiểm tra chứng chỉ TLScritical · Lỗi
TẠM DỪNG (1)
Dọn log cũ hơn 90 ngàylow
Đủ 7 lane theo core.tasks.STATUSES — không gộp, không giấu lane nào. Lane hẹp lại để vừa một màn, hết cuộn ngang.
+
Kiểm kê control — 23 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thêm Tasknútself._add_taskui\schedule_task_tab.py:88giữ nguyên tại chỗ
AI tạo Tasknútself._ai_createui\schedule_task_tab.py:92giữ nguyên tại chỗ
—droplistself._on_view_changedui\schedule_task_tab.py:95→ đổi thành cặp tab Kanban | Lịch
len(runsbảngself._open_artifactui\schedule_task_tab.py:436giữ nguyên tại chỗ
QDialogButtonBox.Closenút hộp thoại—ui\schedule_task_tab.py:461giữ nguyên tại chỗ
Project/workspace mà agent của task này sẽ chạy trong đó — ádroplist—ui\schedule_task_tab.py:523giữ nguyên tại chỗ
vd: Mỗi thứ 2 lúc 9h, dùng Code đọc dữ liệu CAE mới tạo báo ô nhập nhiều dòng—ui\schedule_task_tab.py:537giữ nguyên tại chỗ
Đường dẫn tệp local, cách nhau bằng ;ô nhập—ui\schedule_task_tab.py:544giữ nguyên tại chỗ
Chọn tệp…nútself._ai_pick_filesui\schedule_task_tab.py:546giữ nguyên tại chỗ
https://… các link, cách nhau bằng ;ô nhập—ui\schedule_task_tab.py:553giữ nguyên tại chỗ
Tạo kế hoạchnútself._generateui\schedule_task_tab.py:557giữ nguyên tại chỗ
Tạo template Excel…nútself._export_templateui\schedule_task_tab.py:571giữ nguyên tại chỗ
Chọn file…nútself._pick_import_fileui\schedule_task_tab.py:576giữ nguyên tại chỗ
QDialogButtonBox.Ok | QDialogButtonBox.Cancelnút hộp thoại—ui\schedule_task_tab.py:592giữ nguyên tại chỗ
Chạy ngaymenu chuột phải—ui\schedule_task_tab.py:309giữ nguyên tại chỗ
Sửa taskmenu chuột phải—ui\schedule_task_tab.py:310giữ nguyên tại chỗ
Nhân bản taskmenu chuột phải—ui\schedule_task_tab.py:311giữ nguyên tại chỗ
schedtask.menu_resume' if paused else 'schedtask.menu_pausemenu chuột phải—ui\schedule_task_tab.py:313giữ nguyên tại chỗ
Xem logmenu chuột phải—ui\schedule_task_tab.py:314giữ nguyên tại chỗ
Lịch sử chạy…menu chuột phải—ui\schedule_task_tab.py:315giữ nguyên tại chỗ
Tạo task tiếp theo từ outputmenu chuột phải—ui\schedule_task_tab.py:316giữ nguyên tại chỗ
Xóa taskmenu chuột phải—ui\schedule_task_tab.py:318giữ nguyên tại chỗ
schedtask.menu_delete_selected', n=len(selectedmenu chuột phải—ui\schedule_task_tab.py:348giữ nguyên tại chỗ
+
+
Vấn đề
  • 7 lane bị cắt ở mép phải — lane Paused mất một nửa.
  • Kéo-thả có tác dụng thật: thả vào Running là chạy task ngay (schedule_task_tab.py:265), không cảnh báo.
  • Kanban/Calendar là combo, không phải tab.
+
Thay đổi
  • Giữ đủ 7 lane, thu hẹp cho vừa một màn. Không gộp lane nào.
  • Combo → cặp tab Kanban | Lịch.
  • Lane Running có viền cảnh báo.
+
+
3. Schedule Task — Calendarui/calendar_view.py:88
+
+

Cùng dữ liệu Kanban, xếp theo ngày.

+
Hiện tại
Schedule Task — Calendar
+

Ô ngày: nút + tạo task lúc 09:00 ngày đó

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 7 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
+nútlambda: self.add_requested.emit(self._date_str)ui\calendar_view.py:43giữ nguyên tại chỗ
—danh sáchself._on_item_clickedui\calendar_view.py:49giữ nguyên tại chỗ
Trướcnútlambda: self._shift(-1)ui\calendar_view.py:100giữ nguyên tại chỗ
Hôm naynútself._go_todayui\calendar_view.py:103giữ nguyên tại chỗ
Saunútlambda: self._shift(1)ui\calendar_view.py:105giữ nguyên tại chỗ
—droplistself._on_granularity_changedui\calendar_view.py:110giữ nguyên tại chỗ
—danh sáchlambda item: self.show_month(self.anchor.year, item.data(Qt.UserRole))ui\calendar_view.py:213giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
4. Workspace ▸ Projectui/workspace_tab.py:188
+
+

Khai báo project — đơn vị gom nhóm của app. Mỗi project có sandbox riêng và Instructions chèn vào mọi chat. Đây là bộ chọn project duy nhất.

+
Hiện tại
Workspace ▸ Project
+

Trái: danh sách project — chỉ hiện ở tab này · Phải: Tên · Mô tả · Instructions · thư mục

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Quản lý project
+ Project mới
PROJECT‹
Trạm sạc EV — Cổng vận hành6 đoạn chat · 4 task
Báo cáo tài chính Q32 đoạn chat · 3 task
Cổng tra cứu tài liệu ISO1 đoạn chat · 1 task
Tên
Báo cáo tài chính Q3
Mô tả
Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide.
Instructions
Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.
Mọi con số phải truy được về file nguồn.
Thư mục làm việc
…\workspaces\bao-cao-tai-chinh-q3
Đổi
Mở
Lưu project
+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thu gọn danh sách projectnútlambda: self._set_projects_collapsed(True)ui\workspace_tab.py:90giữ nguyên tại chỗ
—danh sáchself._on_selectui\workspace_tab.py:97→ giữ ở màn Quản lý project + thêm thanh chọn đầu trang
Project mớinútself._createui\workspace_tab.py:101giữ nguyên tại chỗ
Xóanútself._deleteui\workspace_tab.py:105giữ nguyên tại chỗ
—dải tabself._on_tab_changedui\workspace_tab.py:129giữ nguyên tại chỗ
project.nameô nhập—ui\workspace_tab.py:193giữ nguyên tại chỗ
project.descriptionô nhập—ui\workspace_tab.py:194giữ nguyên tại chỗ
vd: "Trả lời bằng tiếng Việt. Team đang xây tool báo cáo X; ô nhập nhiều dòng—ui\workspace_tab.py:203giữ nguyên tại chỗ
Đổi thư mục…nútself._pick_folderui\workspace_tab.py:211giữ nguyên tại chỗ
Mở thư mụcnútself._open_workspaceui\workspace_tab.py:214giữ nguyên tại chỗ
Lưu projectnútself._saveui\workspace_tab.py:223giữ nguyên tại chỗ
+
+
Vấn đề
  • Pane trái đổi danh tính theo tab (workspace_tab.py:310-338): Project → danh sách project, Cowork → History, còn lại → trống.
  • History chỉ tới được từ tab Cowork.
  • Bộ chọn project chỉ có ở tab Project.
  • 2/3 chiều cao dưới là khoảng trống chết.
  • Header “Workspace — Projects” hiện ở mọi sub-tab.
+
Thay đổi
  • History lên sidebar thành RECENTS, luôn thấy.
  • Thanh chọn project ở đầu trang, dùng chung mọi màn.
  • Pane trái cố định, không đổi danh tính.
  • Header đổi theo màn.
+
+
5. Workspace ▸ Coworkui/cowork_tab.py:21
+
+

Chat với agent. Agent đọc/ghi tệp trong sandbox, chạy lệnh, gọi MCP.

+
Hiện tại
Workspace ▸ Cowork
+

Lịch sử: chat gom theo project; nhãn đậm = tên project, chỉ là nhãn · Hội thoại: bong bóng theo trục thời gian · Files: tệp đầu ra, gập được · Composer: Enter gửi · /skill · /agent · Hàng dưới: thư mục sandbox (chỗ thứ 2 lộ project) · model · Định tuyến · Tự chạy

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Gom số liệu doanh thu
qwen2.5-coder
Skills
Cuộc trò chuyện mới
Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình.
Đã đọc cả 6 file. Lưu ý: PB_Marketing.xlsx để cột “Doanh thu” ở cột F thay vì D và có 3 dòng trống ở cuối.

Mình đã chuẩn hoá và xuất tonghop_q3.xlsx — 1.284 dòng, tổng 42.7 tỷ VND.
TỆP ĐẦU RA (3)›
tonghop_q3.xlsx
BaoCao_Q3.pptx
README.md
Nhập yêu cầu… (Enter để gửi)
📎
Gửi
Agent: qwen2.5-coder · Định tuyến: Tắt  ·  ↓292.8K ↑102.7K · $0.31  ·  📁 bao-cao-tai-chinh-q3
+
Kiểm kê control — 26 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Skillsnútself._open_skills_managerui\cowork_tab.py:30giữ nguyên tại chỗ
Cuộc trò chuyện mớinútself.new_sessionui\cowork_tab.py:34giữ nguyên tại chỗ
Thư mục Local…nútself._pick_output_folderui\cowork_tab.py:48giữ nguyên tại chỗ
Model/agent riêng cho tab này — độc lập với tab kiadroplistself._on_agent_changedui\chat_panel.py:165giữ nguyên tại chỗ
Nénnútself._compress_messagesui\chat_panel.py:177giữ nguyên tại chỗ
Thu gọn bảng Filesnútlambda: self._set_io_collapsed(True)ui\chat_panel.py:239giữ nguyên tại chỗ
app_icon('linkmenu chuột phải—ui\chat_panel.py:439giữ nguyên tại chỗ
app_icon('editmenu chuột phải—ui\chat_panel.py:440giữ nguyên tại chỗ
Nhấp đúp để xoá một tin nhắn khỏi hàng đợidanh sáchself._remove_queue_itemui\composer.py:406giữ nguyên tại chỗ
Bấm trên thẻ để gỡ tệp đính kèm nhầmdanh sáchself._remove_attachmentui\composer.py:420giữ nguyên tại chỗ
nútself._pick_attachmentsui\composer.py:443giữ nguyên tại chỗ
composer.queue_btn') if self._busy else 'composer.sendnútself._on_submitui\composer.py:446giữ nguyên tại chỗ
Dừngnútself.stop_requested.emitui\composer.py:450giữ nguyên tại chỗ
Gỡ tệp này (đính kèm nhầmnútlambda _=False, path=p: self._remove_attachment_path(path)ui\composer.py:599giữ nguyên tại chỗ
Thu gọn bảng Lịch sửnútself.collapse_requested.emitui\sidebar.py:104giữ nguyên tại chỗ
Tìm theo tiêu đề hoặc nội dung…ô nhậpself.refresh; self.refreshui\sidebar.py:119giữ nguyên tại chỗ
nútself.refreshui\sidebar.py:123→ lên sidebar cùng RECENTS
—câyself._on_item; self._context_menuui\sidebar.py:132giữ nguyên tại chỗ
Làm mớinútself.refresh_requested.emitui\sidebar.py:147giữ nguyên tại chỗ
sidebar.menu.unpin') if pinned else 'sidebar.menu.pinmenu chuột phải—ui\sidebar.py:305giữ nguyên tại chỗ
Đổi tên…menu chuột phải—ui\sidebar.py:306giữ nguyên tại chỗ
Xóamenu chuột phải—ui\sidebar.py:307giữ nguyên tại chỗ
sidebar.menu.delete_selected', n=len(selectedmenu chuột phải—ui\sidebar.py:332giữ nguyên tại chỗ
titlenútself._toggle_bodyui\chat_view.py:228giữ nguyên tại chỗ
Tự động định tuyến model cho khung chat này. +Tắt: luôn dùng droplistself._on_changedui\routing_toggle.py:66giữ nguyên tại chỗ
Tự chạyô tickself._on_toggledui\routing_toggle.py:133giữ nguyên tại chỗ
+
+
Vấn đề
  • Hàng dưới composer nhồi 5 control + usage/cost + nút thư mục (chat_panel.py:142-181).
  • Không có lối tắt “chat mới” — phải chọn project → mở nav → Cowork.
  • Màn duy nhất thấy được History.
  • Ba pane bóp vùng đọc hội thoại còn chưa tới 60% bề ngang.
  • Biết project nào, nhưng không đổi được. Project hiện ở 2 chỗ (nhãn nhóm Lịch sử, nhãn thư mục đáy) — cả hai chỉ là nhãn. Đổi phải quay về tab Project (workspace_tab.py:323).
+
Thay đổi
  • Bộ chọn project lên sidebar — nó là trạng thái toàn cục (ctx.active_project_id), không phải của riêng màn nào.
  • Bộ chọn project + nút “+ Đoạn chat mới” đặt cạnh nhau ở đầu sidebar: chọn project rồi bấm, không rời màn. Nút cũ trên toolbar giữ nguyên.
  • History lên sidebar, vẫn gom theo project + mục “Tất cả project…”.
  • Usage/cost xuống thanh trạng thái; vùng gõ chỉ còn nhập · đính kèm · gửi.
+
+
6. Workspace ▸ Co4Eui/co4e_tab.py:228
+
+

Xưởng dựng workflow node-graph. Lưu toàn cục, không theo project.

+
Hiện tại
Workspace ▸ Co4E
+

Sidebar: 3 tab icon: Workflows · Agents · Skills — kéo thả được · Dải tab: Flow Status ghim + mỗi workflow một tab · Canvas: node và cạnh · Phải: cấu hình bước đang chọn

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Quy trình phát triển tính năng
+ Bước
Auto ▾
▷ Chạy
WORKFLOWS‹
Quy trình phát triển tính năng5 bước · đã lưu
Rà soát bảo mật định kỳ2 bước · đã lưu
Dựng báo cáo từ Excel3 bước · đã lưu
AGENTS (5)
Phân tích yêu cầuANALYST
Thiết kế giải phápARCHITECT
Lập trình viênCODER
Kiểm thửTESTER
Soạn tài liệuWRITER
SKILLS (5)
Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …
LẦN CHẠY (6)
✓ Quy trình phát triển5/5 · 08-08 15:32
✕ Rà soát bảo mật3/5 · 08-06 16:32
■ Dựng báo cáo từ Excel1/5 · 08-04 18:32
Phân tích yêu cầu
→
Thiết kế
→
Lập trình viên
→
Kiểm thử
CẤU HÌNH BƯỚC›
▾ Cơ bảnLập trình viên · CODER
Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.
▸ Model & quyềnqwen2.5-coder · full
▸ Skills & tệpViết test trước
▸ Agent song songchưa có
+
Kiểm kê control — 52 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—danh sáchlambda _i: self._accept()ui\co4e_tab.py:144giữ nguyên tại chỗ
×nútlambda: self._close_flow_tab_button(btn)ui\co4e_tab.py:341giữ nguyên tại chỗ
Chạynútself._run_selected_in_backgroundui\co4e_tab.py:459giữ nguyên tại chỗ
Mớinútself._new_agentui\co4e_tab.py:476giữ nguyên tại chỗ
Quản lý skill…nútself._manage_skillsui\co4e_tab.py:493giữ nguyên tại chỗ
tip_keynútslotui\co4e_tab.py:502giữ nguyên tại chỗ
—dải tabself._on_flow_tab_changed; self._close_flow_tabui\co4e_tab.py:556→ bỏ; chọn workflow từ danh sách trái
+nútself._new_workflowui\co4e_tab.py:582giữ nguyên tại chỗ
self._wf.nameô nhậpself._on_name_changedui\co4e_tab.py:631giữ nguyên tại chỗ
Thêmnútself._add_blank_stepui\co4e_tab.py:636giữ nguyên tại chỗ
Lưunútlambda: self._save(as_template=False)ui\co4e_tab.py:639giữ nguyên tại chỗ
Auto = mỗi bước tự lập kế hoạch rồi chạy · Plan = chỉ lập kếdroplistself._on_mode_changedui\co4e_tab.py:645giữ nguyên tại chỗ
Chạynútself._on_run_clickedui\co4e_tab.py:650giữ nguyên tại chỗ
shortnútself._open_workspace_folderui\co4e_tab.py:693giữ nguyên tại chỗ
Dừngnútself._stop_selected_runui\co4e_tab.py:701giữ nguyên tại chỗ
Đổi tênnútself._rename_selected_runui\co4e_tab.py:706giữ nguyên tại chỗ
Xóanútself._delete_selected_runui\co4e_tab.py:710giữ nguyên tại chỗ
Xóa đã xongnútlambda: self.manager.clear_finished()ui\co4e_tab.py:714giữ nguyên tại chỗ
0bảngself._open_run_from_table; self._runs_context_menuui\co4e_tab.py:722giữ nguyên tại chỗ
Thu gọn bảng cấu hìnhnútself._toggle_configui\co4e_tab.py:747giữ nguyên tại chỗ
Mở rộng khung tin nhắnnútself._toggle_messagesui\co4e_tab.py:841giữ nguyên tại chỗ
Gửinútself._chat_sendui\co4e_tab.py:873giữ nguyên tại chỗ
icon('editmenu chuột phải—ui\co4e_tab.py:1014giữ nguyên tại chỗ
icon('editmenu chuột phải—ui\co4e_tab.py:1015giữ nguyên tại chỗ
icon('branchmenu chuột phải—ui\co4e_tab.py:1016giữ nguyên tại chỗ
icon('playmenu chuột phải—ui\co4e_tab.py:1017giữ nguyên tại chỗ
icon('trashmenu chuột phải—ui\co4e_tab.py:1018giữ nguyên tại chỗ
Mở flowmenu chuột phải—ui\co4e_tab.py:1400giữ nguyên tại chỗ
Mở thư mục outputmenu chuột phải—ui\co4e_tab.py:1404giữ nguyên tại chỗ
Đổi tênmenu chuột phải—ui\co4e_tab.py:1405giữ nguyên tại chỗ
Xóamenu chuột phải—ui\co4e_tab.py:1406giữ nguyên tại chỗ
step.labelô nhậpself._on_editui\co4e_config_panel.py:44giữ nguyên tại chỗ
step.roleô nhậpself._on_editui\co4e_config_panel.py:48giữ nguyên tại chỗ
—ô nhập nhiều dòngself._on_editui\co4e_config_panel.py:60giữ nguyên tại chỗ
Soạn bằng AInútself._ai_draftui\co4e_config_panel.py:63giữ nguyên tại chỗ
Thông tin/ngữ cảnh bổ sung cho agent hoặc step này (thêm vàoô nhập nhiều dòngself._on_editui\co4e_config_panel.py:77giữ nguyên tại chỗ
Tải danh sách modelnútself._load_modelsui\co4e_config_panel.py:87giữ nguyên tại chỗ
—droplistself._on_editui\co4e_config_panel.py:97giữ nguyên tại chỗ
Tự kiểm traô tickself._on_editui\co4e_config_panel.py:104giữ nguyên tại chỗ
—ô sốself._on_editui\co4e_config_panel.py:106giữ nguyên tại chỗ
Đính kèm tệpnútself._add_attachmentui\co4e_config_panel.py:125giữ nguyên tại chỗ
Bỏnútself._del_attachmentui\co4e_config_panel.py:128giữ nguyên tại chỗ
—danh sáchself._edit_subagentui\co4e_config_panel.py:141giữ nguyên tại chỗ
Thêmnútself._add_subagentui\co4e_config_panel.py:144giữ nguyên tại chỗ
Bỏnútself._del_subagentui\co4e_config_panel.py:147giữ nguyên tại chỗ
Chạynútlambda: self.run_node.emit(self._node_id)ui\co4e_config_panel.py:159giữ nguyên tại chỗ
Chạy từ đâynútlambda: self.run_from.emit(self._node_id)ui\co4e_config_panel.py:163giữ nguyên tại chỗ
Xóa bướcnútlambda: self.delete_node.emit(self._node_id)ui\co4e_config_panel.py:166giữ nguyên tại chỗ
+ Add next stepmenu chuột phải—ui\co4e_canvas.py:188giữ nguyên tại chỗ
→ Connect from heremenu chuột phải—ui\co4e_canvas.py:189giữ nguyên tại chỗ
🗑 Delete stepmenu chuột phải—ui\co4e_canvas.py:190giữ nguyên tại chỗ
🗑 Delete connectionmenu chuột phải—ui\co4e_canvas.py:368giữ nguyên tại chỗ
+
+
Vấn đề
  • Bốn lớp điều hướng chồng nhau: nav → tab icon sidebar → dải tab flow → panel phải.
  • Dải tab flow lặp lại danh sách Workflows ngay bên trái.
  • Panel cấu hình 11 trường dọc, phải cuộn.
+
Thay đổi
  • Bỏ dải tab flow; chọn workflow từ danh sách trái.
  • 3 tab icon → 3 mục có nhãn cùng danh sách.
  • Còn 2 lớp: chọn trái → sửa phải.
+
+
7. Workspace ▸ Folderui/folder_tab.py:238
+
+

Duyệt tệp + nhờ AI sửa. AI không ghi đè — đề xuất diff, bấm Apply mới ghi.

+
Hiện tại
Workspace ▸ Folder
+

Cây trái: hệ thống tệp thật · Viewer: code / HTML / PDF / ảnh / bảng tính · Panel AI: yêu cầu → plan → diff → Apply · Terminal: shell thật, không qua sandbox

+
Đề xuất — bố cục mới
📁 Trạm sạc EV — Cổng vận hành▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Trạm sạc EV — Cổng vận hành
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Thư mục
…\workspaces\tram-sac-ev
Sửa
✨ AI
Lưu
📁 src
 📄 main.py
 📁 billing
  📄 session.py
📁 tests
 📄 test_stations.py
📄 README.md
# billing/session.py
def close_session(sid):
  s = repo.get(sid)
  s.ended_at = None # ← lỗi tính tiền
  return bill(s)
✨ AI SỬA TỆP›
Sửa lỗi tính dư tiền khi phiên bị ngắt đột ngột.
Lấy mốc heartbeat cuối làm ended_at.
- s.ended_at = None
+ s.ended_at = last_heartbeat(sid)
Bỏ
Áp dụng
▸ Terminal — bật khi cần
+
Kiểm kê control — 15 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
self._rootô nhập—ui\folder_tab.py:265giữ nguyên tại chỗ
Mở thư mụcnútself._pick_rootui\folder_tab.py:267giữ nguyên tại chỗ
folder.edit') if not self.mode_btn.isChecked() else 'folder.nútself._toggle_edit_modeui\folder_tab.py:299giữ nguyên tại chỗ
AI Editnútself._toggle_ai_panelui\folder_tab.py:304giữ nguyên tại chỗ
Lưunútself._saveui\folder_tab.py:309giữ nguyên tại chỗ
Mở bằng app ngoàinútself._open_externalui\folder_tab.py:315giữ nguyên tại chỗ
len(rowsbảng—ui\folder_tab.py:534giữ nguyên tại chỗ
Mô tả chỉnh sửa… (vd: thêm xử lý lỗiô nhậpself._ai_sendui\folder_tab.py:736giữ nguyên tại chỗ
Gửinútself._ai_sendui\folder_tab.py:740giữ nguyên tại chỗ
Hủynútself._ai_discardui\folder_tab.py:752giữ nguyên tại chỗ
Áp dụngnútself._ai_applyui\folder_tab.py:755giữ nguyên tại chỗ
terminal.expand_tooltip') if self._collapsed else 'terminal.nútself.toggleui\terminal_panel.py:85giữ nguyên tại chỗ
—ô nhập nhiều dòng—ui\terminal_panel.py:105giữ nguyên tại chỗ
Chạynútself._run_currentui\terminal_panel.py:130giữ nguyên tại chỗ
Mở bằng LibreOfficenútself._open_externalui\libreoffice_view.py:97giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm vùng cùng lúc: path · cây · viewer · panel AI · terminal.
  • Hàng nút viewer trộn 5 chức năng khác loại.
+
Thay đổi
  • Path bar gộp vào tiêu đề.
  • Panel AI thành lớp phủ phải; terminal xuống đáy dạng thanh mỏng.
+
+
8. Workspace ▸ GraphRAGui/structure_graph_view.py:188
+
+

Đồ thị tri thức về cấu trúc mã/tài liệu + agent hỏi đáp trên đó.

+
Hiện tại
Workspace ▸ GraphRAG
+

Đồ thị: node theo loại, cạnh có nhãn quan hệ · Phải: hỏi đáp dựa trên đồ thị

+
Đề xuất — bố cục mới
📁 Cổng tra cứu tài liệu ISO▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Cổng tra cứu tài liệu ISO
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
GraphRAG
…\workspaces\cong-tra-cuu-iso
Quét
Xuất PNG
Đồ thị
Tin nhắn
1.902 node · 3.418 cạnh
ISO 9001
→
Điều 7.5
→
Hồ sơ
HỎI ĐÁP TRÊN ĐỒ THỊ›
Điều khoản nào nói về kiểm soát hồ sơ?
Điều 7.5.3 — Kiểm soát thông tin dạng văn bản. Có 12 tài liệu trùng số hiệu, xem trung_lap.md.
Đặt câu hỏi…
Hỏi
+
Kiểm kê control — 10 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
sctx.config.cowork_output_dir(ô nhập—ui\structure_graph_view.py:220giữ nguyên tại chỗ
Browse…nútself._pickui\structure_graph_view.py:222giữ nguyên tại chỗ
Khóa phạm vi quét vào đúng thư mục sandbox của 1 project — pdroplistself._on_project_changedui\structure_graph_view.py:226giữ nguyên tại chỗ
Scannútself._scanui\structure_graph_view.py:228giữ nguyên tại chỗ
Xem mọi message hội thoại nhóm theo ngày (dạng JSON).nútself._toggle_messagesui\structure_graph_view.py:242giữ nguyên tại chỗ
Xuất PNGnútself._exportui\structure_graph_view.py:248giữ nguyên tại chỗ
—câyself._show_msg_jsonui\structure_graph_view.py:268giữ nguyên tại chỗ
Thu gọn bảng Agentnútlambda: self._set_agent_collapsed(True)ui\structure_graph_view.py:288giữ nguyên tại chỗ
vd. cái gì gọi hàm main? file nào định nghĩa class?ô nhậpself._askui\structure_graph_view.py:299giữ nguyên tại chỗ
Hỏinútself._askui\structure_graph_view.py:301giữ nguyên tại chỗ
+
+
Vấn đề
  • Hai hàng toolbar riêng biệt — nên là một.
  • Nút Messages/Graph đổi hẳn nội dung pane nhưng trông như nút thường.
+
Thay đổi
  • Gộp hai hàng toolbar thành một.
  • Messages/Graph thành cặp tab rõ ràng phía trên pane trái.
+
+
9. Monitoring ▸ Tổng quanui/monitoring_tab.py:132
+
+

Chi phí, tài nguyên máy, sandbox, nhật ký gần đây.

+
Hiện tại
Monitoring ▸ Tổng quan
+

Trái: Token & chi phí · Hoạt động · Tài nguyên · Bảng giá model · Phải: Sandbox · Quyền · Audit log

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
TOKEN & CHI PHÍ
395.4KTổng token
$0.31Chi phí
57Lượt gọi
—Ngân sách
TÀI NGUYÊN
CPU 34% · RAM 2.1/8 GB · Đĩa 41 GB trống
SANDBOX & QUYỀN
Tệp: chỉ trong workspace · Mạng: chặn · Tiến trình: giới hạn 4
NHẬT KÝ GẦN ĐÂY
✕ Chặn đọc personal.xlsx (ngoài sandbox)
✓ pytest tests/test_stations.py → 4 passed
✕ jira.create_issue — 401 token hết hạn
+
Kiểm kê control — 14 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Làm mớinútself.refreshui\monitoring_tab.py:149→ lên sidebar cùng RECENTS
0bảng—ui\monitoring_tab.py:175giữ nguyên tại chỗ
Lọc dòng (hoặc gõ câu hỏi rồi bấm )…ô nhậptable.apply_filterui\monitoring_tab.py:347giữ nguyên tại chỗ
AInútlambda: self._ai_filter(search, ai_btn)ui\monitoring_tab.py:350giữ nguyên tại chỗ
—droplistself._reload_pricing_tableui\monitoring_tab.py:486giữ nguyên tại chỗ
Nhậpnútself._import_pricingui\monitoring_tab.py:496giữ nguyên tại chỗ
Mẫunútself._export_pricingui\monitoring_tab.py:498giữ nguyên tại chỗ
Thêmnútself._add_pricing_rowui\monitoring_tab.py:500giữ nguyên tại chỗ
Tự lấynútself._autolink_pricingui\monitoring_tab.py:502giữ nguyên tại chỗ
Xóanútself._delete_pricing_rowui\monitoring_tab.py:504giữ nguyên tại chỗ
0bảng—ui\monitoring_tab.py:510giữ nguyên tại chỗ
Sửanútself._open_settings_and_refreshui\monitoring_tab.py:547giữ nguyên tại chỗ
Sửanútself._open_settings_and_refreshui\monitoring_tab.py:573giữ nguyên tại chỗ
Xem tất cảnútlambda: self.tabs.setCurrentIndex(self.tabs.indexOf(self.action_page))ui\monitoring_tab.py:586giữ nguyên tại chỗ
+
+
Vấn đề
  • Sáu group box, hai cột — màn dày đặc nhất app.
  • Trộn 3 mối quan tâm: chi phí · tài nguyên · bảo mật.
  • Bảng giá model nhét chung hàng với thanh CPU/RAM.
+
Thay đổi
  • Tách thành các mục có tiêu đề, cuộn dọc một cột chính.
  • Bảng giá model tách ra thành mục riêng.
+
+
10. Monitoring ▸ Sự kiện bảo mậtui/monitoring_tab.py:132
+
+

Nhật ký lần agent chạm thứ nhạy cảm: lệnh bị chặn, truy cập ngoài sandbox.

+
Hiện tại
Monitoring ▸ Sự kiện bảo mật
+

Ô lọc: có nút ✨ biến câu hỏi thành từ khoá

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
11. Monitoring ▸ Lịch sử gọi MCPui/monitoring_tab.py:132
+
+

Mọi lần agent gọi MCP server ngoài.

+
Hiện tại
Monitoring ▸ Lịch sử gọi MCP
+

Bảng: không có ô lọc như 2 màn log kia — khác biệt không chủ đích

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
12. Monitoring ▸ Nhật ký hành độngui/monitoring_tab.py:132
+
+

Nhật ký cấp ứng dụng: ai đổi cấu hình, ai chạy task.

+
Hiện tại
Monitoring ▸ Nhật ký hành động
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
13. Monitoring ▸ Trạng thái Agentui/monitoring_tab.py:132
+
+

Agent nào đang bật và nguồn định nghĩa.

+
Hiện tại
Monitoring ▸ Trạng thái Agent
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
14. Monitoring ▸ Agents Adminui/monitoring_tab.py:132
+
+

Quản trị agent hệ thống. Cũng là nơi chọn model cho robot trợ giúp.

+
Hiện tại
Monitoring ▸ Agents Admin
+

Nút Kiểm tra: probe provider thật

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
agent.name if agent else ô nhập—ui\agents_admin_tab.py:55giữ nguyên tại chỗ
agent.prompt if agent else ô nhập nhiều dòng—ui\agents_admin_tab.py:65giữ nguyên tại chỗ
—droplistself._refresh_model_comboui\agents_admin_tab.py:70→ menu tài khoản ở đáy sidebar
Lấy danh sách model thực tế của provider này để chọn từ dropnútself._load_live_modelsui\agents_admin_tab.py:89giữ nguyên tại chỗ
Kích hoạtô tick—ui\agents_admin_tab.py:98giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\agents_admin_tab.py:101giữ nguyên tại chỗ
0bảng—ui\agents_admin_tab.py:169giữ nguyên tại chỗ
Thêmnútself._addui\agents_admin_tab.py:178giữ nguyên tại chỗ
Sửanútself._editui\agents_admin_tab.py:182giữ nguyên tại chỗ
Xóanútself._deleteui\agents_admin_tab.py:185giữ nguyên tại chỗ
Kiểm tranútself._check_allui\agents_admin_tab.py:188giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
15. Monitoring ▸ Công cụui/monitoring_tab.py:132
+
+

Bật/tắt công cụ dựng sẵn và khai báo kết nối ngoài. Màn duy nhất còn hiện dải tab bên trong.

+
Hiện tại
Monitoring ▸ Công cụ
+

Tool: công cụ dựng sẵn + tự kiểm tra Internet · Connector: MCP · REST · MS365 · Jira

+
Đề xuất — bố cục mới
📁 Báo cáo tài chính Q3▾
+ Đoạn chat mới
Project
Cowork
Co4E
Folder
GraphRAG
Schedule Task
RECENTS
📁 Báo cáo tài chính Q3
📌 Gom số liệu doanh thu
Dựng slide trình bày Q3
Tất cả project…
Dashboard
Monitoring
👤 local · Ollama ▾
Monitoring
Tổng quan
Bảo mật
MCP
Hành động
Agent
Agents Admin
Công cụ
Icon
Tool
Connector
Kiểm tra Internet
read_fileĐọc tệp trong sandbox — ☑ bật
write_fileGhi tệp trong sandbox — ☑ bật
run_commandChạy lệnh shell — ☑ bật
fetch_urlTải nội dung URL — ☑ bật · ✓ Internet OK
image_genSinh ảnh — ☐ tắt
+
Kiểm kê control — 15 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—ô tickon_toggleui\tools_admin_tab.py:32giữ nguyên tại chỗ
0bảng—ui\tools_admin_tab.py:57giữ nguyên tại chỗ
Kiểm tra Internetnútself._test_internetui\tools_admin_tab.py:72giữ nguyên tại chỗ
Làm mớinútself.refreshui\tools_admin_tab.py:80→ lên sidebar cùng RECENTS
Dán bất kỳ link Jira nào — tự điền Base URLô nhậpself._on_pasteui\connectors_panel.py:42giữ nguyên tại chỗ
jira.get('base_url', ô nhập—ui\connectors_panel.py:46giữ nguyên tại chỗ
jira.get('email', ô nhập—ui\connectors_panel.py:48giữ nguyên tại chỗ
jira.get('api_token', ô nhập—ui\connectors_panel.py:49giữ nguyên tại chỗ
Kiểm tra kết nốinútself._testui\connectors_panel.py:58giữ nguyên tại chỗ
Lưunútself._save_closeui\connectors_panel.py:60giữ nguyên tại chỗ
Kết nối tới connector bên ngoàiô tickself._on_connect_external_toggledui\connectors_panel.py:133giữ nguyên tại chỗ
—câylambda *_: self._ext_edit()ui\connectors_panel.py:144giữ nguyên tại chỗ
Thêm connector…nútself._ext_addui\connectors_panel.py:155giữ nguyên tại chỗ
Sửanútself._ext_editui\connectors_panel.py:159giữ nguyên tại chỗ
Xóanútself._ext_deleteui\connectors_panel.py:162giữ nguyên tại chỗ
+
+
Vấn đề
  • Màn duy nhất còn hiện dải tab — 7 màn Monitoring kia bị ẩn. Không nhất quán.
  • Tab Tool/Connector lọt thỏm trong một mục nav.
  • Cấu hình kết nối tách khỏi Settings → thiết lập ở 2 nơi.
+
Thay đổi
  • Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.
+
+
16. Monitoring ▸ Iconui/monitoring_tab.py:132
+
+

Thư viện icon, dùng lại khi đặt icon cho agent Co4E.

+
Hiện tại
Monitoring ▸ Icon
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 4 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Tìm icon có sẵn…ô nhậpself._reload_builtinui\icons_admin_tab.py:43giữ nguyên tại chỗ
Thêm tệp SVGnútself._add_iconui\icons_admin_tab.py:58giữ nguyên tại chỗ
Dán SVGnútself._add_from_svg_textui\icons_admin_tab.py:60giữ nguyên tại chỗ
Xóa tùy chỉnhnútself._delete_iconui\icons_admin_tab.py:62giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
17. Settingsui/settings_dialog.py:26
+
+

Thiết lập toàn app. Cuộn dọc, không mục lục.

+
Hiện tại
Settings
+

5 nhóm: Ngôn ngữ · Provider · Bảo mật (khoá mật khẩu) · Tham số · Routing

+
Đề xuất — bố cục mới
Cài đặt
Chungngôn ngữ · giao diện · khay
AI ProviderOllama · qwen2.5-coder
Bảo mật sandbox🔒 cần mở khoá
Tham sốđính kèm · GraphRAG · tài nguyên
Auto Model Routingđang Tắt
Ngôn ngữ hiển thị
Tiếng Việt (VN)
Giao diện
Theo hệ thống
Nhà cung cấp AI
Ollama (local models)
Thu nhỏ xuống khay khi đóng
☑ Bật
Huỷ
Lưu
+
Kiểm kê control — 23 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Giữ chạy nền trong khay hệ thống khi đóng cửa sổô tick—ui\settings_dialog.py:56giữ nguyên tại chỗ
Hiện thông báo khay hệ thống khi tác vụ xong hoặc lỗiô tick—ui\settings_dialog.py:59giữ nguyên tại chỗ
—droplistself._on_provider_edit_changedui\settings_dialog.py:70→ menu tài khoản ở đáy sidebar
conf.get('base_url', ô nhập—ui\settings_dialog.py:77giữ nguyên tại chỗ
ô nhập—ui\settings_dialog.py:103giữ nguyên tại chỗ
Unlocknútself._sandbox_unlockui\settings_dialog.py:107giữ nguyên tại chỗ
Xác nhận trước khi Cowork chạy lệnhô tick—ui\settings_dialog.py:121giữ nguyên tại chỗ
Chặn mạng cho lệnh do agent chạyô tick—ui\settings_dialog.py:126giữ nguyên tại chỗ
Enable Agent Security (command validationô tick—ui\settings_dialog.py:136giữ nguyên tại chỗ
AI check commandsô tick—ui\settings_dialog.py:142giữ nguyên tại chỗ
Số tệp tối đa đính kèm vào một tin nhắn.ô số—ui\settings_dialog.py:178giữ nguyên tại chỗ
Giới hạn nội dung mỗi tệp đính kèm đưa vào prompt; phần vượtô số—ui\settings_dialog.py:183giữ nguyên tại chỗ
Giới hạn số node trong đồ thị Cấu trúc (0 = không giới hạn).ô số—ui\settings_dialog.py:194giữ nguyên tại chỗ
Giới hạn số cạnh trong đồ thị Cấu trúc (0 = không giới hạn).ô số—ui\settings_dialog.py:200giữ nguyên tại chỗ
routing.get('judge_model', ô nhập—ui\settings_dialog.py:279giữ nguyên tại chỗ
Đánh giá lại ngaynútself._routing_reassess_nowui\settings_dialog.py:282giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\settings_dialog.py:299giữ nguyên tại chỗ
valueô nhập—ui\settings_dialog.py:316giữ nguyên tại chỗ
Tảinútlambda: self._load_models(self.provider_combo.currentData(), combo, stui\settings_dialog.py:368giữ nguyên tại chỗ
Test kết nốinútlambda: self._test_connection(self.provider_combo.currentData(), statuui\settings_dialog.py:374giữ nguyên tại chỗ
codeô nhập—ui\settings_dialog.py:496giữ nguyên tại chỗ
Copy mãnútlambda: QGuiApplication.clipboard().setText(code)ui\settings_dialog.py:503giữ nguyên tại chỗ
Mở linknútlambda: webbrowser.open(flow.get('verification_uri_complete') or url)ui\settings_dialog.py:506giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm group cuộn dọc, không mục lục.
  • Sandbox khoá bằng mật khẩu hard-code (settings_dialog.py:115).
  • Provider/Ngôn ngữ/Giao diện ở top bar, tách khỏi Settings.
+
Thay đổi
  • Thêm cột mục lục bên trái; gom Provider/Ngôn ngữ/Giao diện vào đây.
+
+
18. Task Editorui/task_editor_dialog.py:55
+
+

Khai báo tác vụ hẹn giờ: nội dung, lịch lặp, phụ thuộc, thông báo.

+
Hiện tại
Task Editor
+

5 nhóm: Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi

+
Đề xuất — bố cục mới
Sửa task
① Nội dung
② Lịch chạy
③ Liên kết
Tiêu đề
Gửi báo cáo doanh thu hằng ngày 08:00
Mô tả
Gom số liệu ngày hôm trước, dựng bảng và gửi email cho nhóm kế toán.
Project
Báo cáo tài chính Q3
Chạy bằng
Agent · qwen2.5-coder
Ưu tiên
medium
Huỷ
Lưu
+
Kiểm kê control — 26 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
self.task.get('title', ô nhập—ui\task_editor_dialog.py:96giữ nguyên tại chỗ
self.task.get('description', ô nhập nhiều dòng—ui\task_editor_dialog.py:100giữ nguyên tại chỗ
nútself._gen_prompt_from_descriptionui\task_editor_dialog.py:102giữ nguyên tại chỗ
—droplistself._refresh_model_comboui\task_editor_dialog.py:135→ menu tài khoản ở đáy sidebar
Tải danh sách model của provider nàynútself._load_live_modelsui\task_editor_dialog.py:147giữ nguyên tại chỗ
AI agent = chạy một agent Cowork với model đã chọn. Co4E flodroplistself._on_run_kind_changedui\task_editor_dialog.py:170giữ nguyên tại chỗ
Flow Co4E đã lưu mà task này sẽ chạy (có sẵn hoặc của bạn).droplist—ui\task_editor_dialog.py:176giữ nguyên tại chỗ
Thông thường = chạy một lần (hoặc thủ công). Tự động = cronjdroplistself._on_task_mode_changedui\task_editor_dialog.py:188giữ nguyên tại chỗ
Bật lịch chạyô tick—ui\task_editor_dialog.py:217giữ nguyên tại chỗ
—droplistself._on_repeat_changedui\task_editor_dialog.py:232giữ nguyên tại chỗ
sched.get('cron_expression') or ô nhập—ui\task_editor_dialog.py:238giữ nguyên tại chỗ
Chọn một lịch mẫu — sẽ điền đúng cú pháp vào ô cron.droplistself._on_cron_sampleui\task_editor_dialog.py:242giữ nguyên tại chỗ
Chỉ ngày làm việc (bỏ T7/CNô tick—ui\task_editor_dialog.py:258giữ nguyên tại chỗ
Bỏ qua ngày nghỉ lễô tick—ui\task_editor_dialog.py:260giữ nguyên tại chỗ
sched.get('holiday_country', 'VN') or 'VNô nhập—ui\task_editor_dialog.py:262giữ nguyên tại chỗ
—droplistself._on_notify_changedui\task_editor_dialog.py:274giữ nguyên tại chỗ
ex_sched.get('notify_email', '') or ô nhập—ui\task_editor_dialog.py:280giữ nguyên tại chỗ
inp.get('manual_text') or ô nhập nhiều dòng—ui\task_editor_dialog.py:313giữ nguyên tại chỗ
—nútself._add_filesui\task_editor_dialog.py:324giữ nguyên tại chỗ
—nútlambda: self._remove_selected(self.files_list)ui\task_editor_dialog.py:328giữ nguyên tại chỗ
—nútself._add_linkui\task_editor_dialog.py:349giữ nguyên tại chỗ
—nútlambda: self._remove_selected(self.links_list)ui\task_editor_dialog.py:353giữ nguyên tại chỗ
—droplistself._check_chainui\task_editor_dialog.py:376giữ nguyên tại chỗ
Dùng output task này làm input task sauô tick—ui\task_editor_dialog.py:385giữ nguyên tại chỗ
Cần phê duyệt (không tự chạy theo lịch; chờ bấm Chạy ngayô tick—ui\task_editor_dialog.py:421giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\task_editor_dialog.py:430giữ nguyên tại chỗ
+
+
Vấn đề
  • Năm group dọc — form dài nhất app, không thấy đang ở bước nào.
+
Thay đổi
  • Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết.
+
+
19. Skills managerui/skills_dialog.py:108
+
+

Quản lý skill — khối hướng dẫn tái dùng, gõ /skill để chèn.

+
Hiện tại
Skills manager
+

Ô tick: chính là bật/tắt skill

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 13 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
skill.name if skill else ô nhập—ui\skills_dialog.py:34giữ nguyên tại chỗ
skill.description if skill else ô nhập—ui\skills_dialog.py:39giữ nguyên tại chỗ
Tạo từ mô tảnútself._gen_instructionsui\skills_dialog.py:44giữ nguyên tại chỗ
skill.instructions if skill else ô nhập nhiều dòng—ui\skills_dialog.py:50giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\skills_dialog.py:55giữ nguyên tại chỗ
Tự động tạonútself._auto_generateui\skills_dialog.py:127giữ nguyên tại chỗ
Từ file template…nútself._from_templateui\skills_dialog.py:132giữ nguyên tại chỗ
Nhập…nútself._importui\skills_dialog.py:136giữ nguyên tại chỗ
Xuất .mdnútself._export_mdui\skills_dialog.py:140giữ nguyên tại chỗ
Nhân bảnnútself._duplicateui\skills_dialog.py:144giữ nguyên tại chỗ
Sửanútself._editui\skills_dialog.py:148giữ nguyên tại chỗ
Xóanútself._deleteui\skills_dialog.py:151giữ nguyên tại chỗ
Đóngnútself.acceptui\skills_dialog.py:154giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
20. Skill editorui/skills_dialog.py:23
+
+

Soạn skill: tên, mô tả, hướng dẫn.

+
Hiện tại
Skill editor
+

✨: sinh hướng dẫn từ mô tả ngắn

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
21. File view & AI editui/file_edit_dialog.py:50
+
+

Xem và nhờ AI sửa tệp, mở từ panel Files trong chat.

+
Hiện tại
File view & AI edit
+

Tệp nhị phân: trích văn bản, chỉ đọc · Lưu: tạo .bak trước khi ghi

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 8 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
spô nhập—ui\file_edit_dialog.py:66giữ nguyên tại chỗ
Mở file khác…nútself._browseui\file_edit_dialog.py:68giữ nguyên tại chỗ
Tải lại từ đĩanútself._reloadui\file_edit_dialog.py:73giữ nguyên tại chỗ
Mở một file để xem hoặc chỉnh sửa.ô nhập nhiều dòng—ui\file_edit_dialog.py:84giữ nguyên tại chỗ
Nói cho AI cách sửa file này (vd: 'sửa lỗi chính tả', 'dịch ô nhậpself._ai_editui\file_edit_dialog.py:94giữ nguyên tại chỗ
Sửa bằng AInútself._ai_editui\file_edit_dialog.py:97giữ nguyên tại chỗ
Lưunútself._saveui\file_edit_dialog.py:106giữ nguyên tại chỗ
Đóngnútself.rejectui\file_edit_dialog.py:110giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
22. Co4E agent editorui/co4e_agent_dialog.py:23
+
+

Định nghĩa agent Co4E: tính cách, quyền, model, skill.

+
Hiện tại
Co4E agent editor
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 9 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
agent.nameô nhập—ui\co4e_agent_dialog.py:32giữ nguyên tại chỗ
agent.role or 'AGENTô nhập—ui\co4e_agent_dialog.py:34giữ nguyên tại chỗ
agent.instructionsô nhập nhiều dòng—ui\co4e_agent_dialog.py:42giữ nguyên tại chỗ
Soạn bằng AInútself._ai_draftui\co4e_agent_dialog.py:46giữ nguyên tại chỗ
getatagent, 'context', ô nhập nhiều dòng—ui\co4e_agent_dialog.py:59giữ nguyên tại chỗ
Tải danh sách modelnútself._load_modelsui\co4e_agent_dialog.py:68giữ nguyên tại chỗ
Đính kèm tệpnútself._add_attachmentui\co4e_agent_dialog.py:99giữ nguyên tại chỗ
Bỏnútself._del_attachmentui\co4e_agent_dialog.py:102giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\co4e_agent_dialog.py:113giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
23. External connectorui/ext_connector_dialog.py:23
+
+

Khai báo kết nối ngoài, 2 chế độ.

+
Hiện tại
External connector
+

MCP (stdio): lệnh + tham số · REST: URL · key · header · Test: thử kết nối thật

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 11 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
—droplistself._apply_presetui\ext_connector_dialog.py:35giữ nguyên tại chỗ
connector.get('name', ô nhập—ui\ext_connector_dialog.py:43giữ nguyên tại chỗ
—droplistlambda i: self.stack.setCurrentIndex(self.mode_combo.currentData() != ui\ext_connector_dialog.py:47giữ nguyên tại chỗ
connector.get('command', ô nhập—ui\ext_connector_dialog.py:59giữ nguyên tại chỗ
'.join(connector.get('args', []) or []ô nhập—ui\ext_connector_dialog.py:62giữ nguyên tại chỗ
connector.get('base_url', ô nhập—ui\ext_connector_dialog.py:69giữ nguyên tại chỗ
connector.get('api_key', ô nhập—ui\ext_connector_dialog.py:72giữ nguyên tại chỗ
connector.get('auth_header', 'Authorizationô nhập—ui\ext_connector_dialog.py:75giữ nguyên tại chỗ
connector.get('auth_scheme', 'Bearerô nhập—ui\ext_connector_dialog.py:77giữ nguyên tại chỗ
Kiểm tra kết nốinútself._test_connectionui\ext_connector_dialog.py:90giữ nguyên tại chỗ
QDialogButtonBox.Save | QDialogButtonBox.Cancelnút hộp thoại—ui\ext_connector_dialog.py:103giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
24. Permission requestui/permission_dialog.py:13
+
+

Chốt chặn cuối trước khi agent làm việc có hậu quả. Bật Tự chạy thì bỏ qua bước này.

+
Hiện tại
Permission request
+

Xem trước: lệnh sắp chạy hoặc diff sắp ghi

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
25. Admin agent editorui/agents_admin_tab.py:35
+
+

Soạn agent hệ thống: gắn vào chức năng nào, provider/model gì.

+
Hiện tại
Admin agent editor
+ +
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+ +
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
26. Login (dead screen — not wired)ui/login_dialog.py:57
+
+

Màn đăng nhập — đã dựng xong nhưng không nơi nào gọi. App khởi động thẳng với user \“local\”, quyền admin.

+
Hiện tại
Login (dead screen — not wired)
+

3 trang: Khởi tạo · Đăng nhập · Offline

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 9 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
Thoátnútself.rejectui\login_dialog.py:87giữ nguyên tại chỗ
self.ctx.config.shared_dirô nhập—ui\login_dialog.py:121giữ nguyên tại chỗ
Chọn…nútself._bs_browseui\login_dialog.py:122giữ nguyên tại chỗ
Tạo tài khoản Adminnútself._bs_create_adminui\login_dialog.py:137giữ nguyên tại chỗ
cached_codeô nhập—ui\login_dialog.py:186giữ nguyên tại chỗ
self.ctx.config.auth.get('last_department', ô nhập—ui\login_dialog.py:199giữ nguyên tại chỗ
Đăng nhậpnútlambda: self._do_login(shared_dir)ui\login_dialog.py:209giữ nguyên tại chỗ
login.offline_btn', role=rolenútlambda: self._finish_login(Account(username=username, role=role, code=ui\login_dialog.py:256giữ nguyên tại chỗ
Thử lạinútself._retryui\login_dialog.py:263giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+
27. Help dock — expanded panelui/help_agent_widget.py:79
+
+

Robot trợ giúp nổi, có mặt trên mọi màn. Cố tình không có công cụ.

+
Hiện tại
Help dock — expanded panel
+

3 trạng thái: tab mép → huy hiệu → panel chat

+
Đề xuất — bố cục mới

Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

+
Kiểm kê control — 5 mục (trích bằng AST, không đọc tay)
NhãnLoạiHàm xử lýNguồnSau khi sửa
selfnútself._show_launcherui\help_agent_widget.py:169giữ nguyên tại chỗ
selfnútself._hide_to_edgeui\help_agent_widget.py:178giữ nguyên tại chỗ
headernútself._collapseui\help_agent_widget.py:214giữ nguyên tại chỗ
rowô nhậpself._sendui\help_agent_widget.py:236giữ nguyên tại chỗ
rownútself._sendui\help_agent_widget.py:241giữ nguyên tại chỗ
+
+
Vấn đề
  • Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng.
+
Thay đổi
  • Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên.
+
+ +

Phần 4 — Màn chết (chỉ ghi nhận)

+

Sáu màn có trong code nhưng không tới được — tổng 64 control +(accounts 18 · flow_dialog 28 · agent_manager 7 · skill_manager 7 · mcp_servers 4). +Không nằm trong kiểm kê phía trên vì không có đường nào tới; chỉ ghi nhận, không đề xuất gỡ.

+
Thành phầnVị tríTình trạng
AccountsTabui/accounts_tab.py:153Quản lý tài khoản/nhóm đầy đủ, nhưng không được gắn vào MonitoringTab.
LoginDialogui/login_dialog.py:57Màn đăng nhập hoàn chỉnh; app.py:860 bỏ qua, hard-code user “local”.
FlowBuilderDialogui/flow_dialog.py:34Bị Co4E thay thế; không nơi nào gọi.
AgentManagerTabui/agent_manager_tab.py:28Chỉ dùng bởi FlowBuilderDialog → cũng không tới được.
SkillManagerTabui/skill_manager_tab.pyChỉ dùng bởi FlowBuilderDialog → cũng không tới được.
McpServerEditDialogui/mcp_servers_dialog.py:15Bị ExtConnectorEditDialog thay thế.
+
Ngoài phạm vi: settings_dialog.py:115 hard-code mật khẩu +Sandbox; hai lớp cùng tên CustomAgent +(custom_agents.py:23 · co4e.py:117).
+
\ No newline at end of file diff --git a/podman-compose.preview.vibeflow.yaml b/podman-compose.preview.vibeflow.yaml new file mode 100644 index 0000000..1344e09 --- /dev/null +++ b/podman-compose.preview.vibeflow.yaml @@ -0,0 +1,23 @@ +services: + cowork-desktop: + image: python:3.11-slim-bookworm + container_name: cowork-local-desktop-preview + working_dir: /workspace + volumes: + - /workspace:/workspace:Z + - pip-cache:/root/.cache/pip:Z + ports: + - "6080:6080" + environment: + PYTHONUNBUFFERED: "1" + QT_QPA_PLATFORM: "vnc:size=1280x800:depth=32" + QT_QPA_VNC_HOST: "127.0.0.1" + QT_QPA_VNC_PORT: "5900" + QSG_RHI_BACKEND: "software" + PYTHONPATH: "/opt" + entrypoint: ["/bin/sh", "/workspace/.vibeflow-preview/entrypoint.sh"] + command: [] + restart: unless-stopped + +volumes: + pip-cache: {} diff --git a/preview-desktop b/preview-desktop new file mode 100644 index 0000000..e69de29 diff --git a/requirements (cloud copy).txt b/requirements (cloud copy).txt new file mode 100644 index 0000000..09f75e4 --- /dev/null +++ b/requirements (cloud copy).txt @@ -0,0 +1,9 @@ +PySide6>=6.6 +pydantic>=2 +requests +psutil +pygments +openpyxl +python-pptx +networkx +pytest diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..a762b54 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,24 @@ +# Cowork-Local BamBOO — dependencies +# Install: pip install -r requirements.txt + +# --- Core UI framework --- +PySide6>=6.6.0 + +# --- HTTP client --- +requests>=2.31.0 + +# --- Process & resource monitoring --- +psutil>=5.9.0 + +# --- Document handling --- +openpyxl>=3.1.0 # Excel (.xlsx) creation & reading +python-pptx>=0.6.0 # PowerPoint (.pptx) editing +pypdf>=4.0.0 # PDF text extraction (preferred) +# PyPDF2>=3.0.0 # PDF fallback (optional, pypdf preferred) + +# --- Microsoft 365 integration --- +msal>=1.24.0 # OAuth device-code flow for MS365 +keyring>=24.0.0 # OS credential store (token cache) + +# --- MCP (Model Context Protocol) --- +mcp>=1.0.0 # MCP client SDK (stdio transport) \ No newline at end of file diff --git a/slides/cowork-local-bamboo/deck.html b/slides/cowork-local-bamboo/deck.html new file mode 100644 index 0000000..1569d14 --- /dev/null +++ b/slides/cowork-local-bamboo/deck.html @@ -0,0 +1,677 @@ + + + + + +Cowork-Local BamBOO + + + +
+
Cowork-Local BamBOO
+
1 / N
+
+ + + +
+
+ + + + + + + + +

Cowork-Local BamBOO

+

Enterprise AI Business Assistant

+ Slide 1 / 11 +
+

Mục đích ứng dụng

+
+
+
Vấn đề
+
    +
  • Nhân viên văn phòng cần AI hỗ trợ tác vụ hàng ngày: tổng hợp báo cáo, phân tích dữ liệu, tạo tài liệu.
  • +
  • Giải pháp hiện tại quá đơn giản (chat thuần túy) hoặc quá phức tạp (cần kiến thức lập trình).
  • +
  • Thiếu công cụ AI doanh nghiệp: bảo mật, quản lý tài khoản, tích hợp hệ thống văn phòng.
  • +
+
+
+
Giải pháp — Cowork-Local BamBOO
+
    +
  • Ứng dụng desktop AI đa năng cho doanh nghiệp.
  • +
  • Hỗ trợ 3 ngôn ngữ: Việt, Anh, Nhật.
  • +
  • Tích hợp Microsoft 365 (OneDrive, SharePoint).
  • +
  • Quản lý tài khoản (Admin/Sub-admin/User), phân quyền, giám sát chi phí.
  • +
  • Không cần kiến thức lập trình — dùng như chat, kết quả là file thực tế.
  • +
+
+
+ Slide 2 / 13 +
+

Kiến trúc tổng quan

+
+
+
UI Layer (PySide6/Qt)
+
Dashboardthống kê chi phí
+
ScheduleKanban board
+
Workspace5 sub-tabs
+
MonitoringSecurity · MCP · Logs
+
Settings · Login · Help
+
+
+
Core Business Logic
+
Chat AgentCowork
+
Code Agent
+
Co4E WorkflowDAG multi-agent
+
Schedule Taskcron + chaining
+
Projects · Accounts · Skills
+
Doc Extract · PPTX · XLSX
+
+
+
Routing & Providers
+
Auto Model Routingclassify → score → select
+
OpenAI Compatible
+
Anthropic · Ollama
+
Copilot · Codex
+
Model Pricing · Benchmark
+
+
+
Security & Integration
+
Agent Security3 lớp bảo mật
+
Sandbox Managerrisk-based
+
MCP Client + Servers
+
MS365 · Jira · Ext
+
Audit Log · Usage Tracker
+
Doc Extract · Image Gen
+
+
+
Bảo mật xuyên suốt: Risk Classifier → Backend Selector → Execution (Direct / Integrity Job / AppContainer / Win Sandbox).
+
Kiến trúc 4 lớp: UI → Core → Routing/Providers → Security/Integration. Mỗi layer có thể mở rộng độc lập. Tổng cộng 50+ module trong core/, 30+ UI components. Hỗ trợ Windows, macOS, Linux. I18n: Tiếng Việt, English, 日本語. Dark/Light theme tích hợp sẵn.
+
Thiết kế modular: mỗi layer giao tiếp qua interface rõ ràng, dễ dàng thay thế hoặc mở rộng thành phần.
+ Slide 3 / 13 +
+

Luồng xử lý chính: Chat Cowork

+
+
User Input + file
+ → +
Chat Agentapply skills · rules · project ctx
+ → +
Auto Model Routingclassify → rank → switch
+ → +
Agent Security L1prompt validate
+
+
+
Provider Chat Loopstreaming response
+ → +
Tool Callingfile / command / MCP / MS365
+ → +
Security L2+L3attachment + command check
+ → +
Output Files.xlsx · .pptx · .docx · .md
+
+
Kết quả là file thực tế — không chỉ là câu trả lời chat, AI tạo ra tài liệu/báo cáo có thể dùng ngay.
+
Tool calling hỗ trợ: file I/O, command execution, MCP connectors, MS365 Graph API, Jira, và external connectors framework.
+
Đa provider: OpenAI, Anthropic, Ollama, GitHub Copilot, Codex — tự động chọn model phù hợp.
+ Slide 4 / 13 +
+ +
+

Luồng xử lý: Co4E Workflow

+
+
Wave 0Step A
+ → +
Wave 1 (parallel)Sub 1 + Sub 2 chạy đồng thời
+ → +
Wave 2 (join)Coordinator tổng hợp
+
+
+
+
Mỗi step
+
    +
  • Agent persona (built-in / custom)
  • +
  • Model riêng
  • +
  • Permission preset
  • +
  • Self-verify (quality gate)
  • +
  • Skills đính kèm
  • +
+
+
+
Run modes
+
    +
  • Auto — AI tự thực hiện
  • +
  • Plan — read-only
  • +
  • Manual — step-by-step
  • +
+
+
+
Lưu ý
+
    +
  • Workflow là DAG — không có retry/loop/condition/branch tự động.
  • +
  • Parallel node chạy sub-agent đồng thời + join stage.
  • +
+
+
+ Slide 5 / 13 +
+ +
+

Luồng xử lý: Schedule Task

+
+
Backlog
+ → +
Scheduled
+ → +
Running
+ → +
Done
+
+
+
+
Trạng thái phụ
+
    +
  • Paused
  • +
  • Failed
  • +
  • Waiting Input
  • +
+
+
+
Lập lịch
+
    +
  • One-shot · Daily · Weekly · Monthly · Cron
  • +
  • Skip: working days + holiday calendar
  • +
  • Task chaining (fan-in depends_on)
  • +
+
+
+
Kiểm soát
+
    +
  • Retry: max_retry
  • +
  • Timeout: per-task (600s)
  • +
  • Notify: Teams webhook / Outlook desktop
  • +
+
+
+
Hỗ trợ import task từ CSV/Excel, tự động chain theo thứ tự, và lịch nghỉ lễ (VN/JP/US/KR…).
+ Slide 6 / 13 +
+

Chức năng hiện tại (1/4)

+

Chat & Agent

+ + + + + + + + + +
Chức năngMô tảTrạng thái
Cowork ChatChat với AI, đính kèm file, nhận output file thực tế✅ Hoàn chỉnh
Code AgentAgent chuyên biệt cho task phát triển phần mềm✅ Hoàn chỉnh
AI EditChỉnh sửa file bằng AI, hỗ trợ tạo ảnh minh họa✅ Hoàn chỉnh
Help AgentTrợ lý hỗ trợ sử dụng app, luôn sẵn sàng✅ Hoàn chỉnh
Multi-providerOpenAI, Anthropic, Ollama, GitHub Copilot, Codex✅ Hoàn chỉnh
+

Workspace & Project

+ + + + + + + + +
Chức năngMô tảTrạng thái
ProjectsMỗi project có instructions + sandbox riêng✅ Hoàn chỉnh
Structure GraphĐồ thị cấu trúc từ code/tài liệu (AST-based)✅ Hoàn chỉnh
Folder ViewerXem & chỉnh sửa file (PDF/DOCX/XLSX)✅ Hoàn chỉnh
TerminalTerminal tích hợp trong app✅ Hoàn chỉnh
+
Tổng cộng 9 tính năng trong nhóm Chat & Agent và Workspace & Project, tất cả đã hoàn chỉnh và sẵn sàng sử dụng. Multi-provider hỗ trợ OpenAI, Anthropic, Ollama, GitHub Copilot, Codex.
+ Slide 7 / 13 +
+ +
+

Chức năng hiện tại (2/4) — Automation · Integration

+

Automation

+ + + + + + + + +
Chức năngMô tảTrạng thái
Co4E WorkflowDAG workflow đa bước, multi-agent, chạy song song✅ Hoàn chỉnh
Schedule TaskLên lịch task tự động, Kanban board, cron, chaining✅ Hoàn chỉnh
SkillsThư viện skill tích hợp sẵn (5 skills), Skill Manager✅ Hoàn chỉnh
Plan ChecklistAgent tự động tạo & theo dõi checklist công việc✅ Hoàn chỉnh
+

Integration

+ + + + + + + + +
Chức năngMô tảTrạng thái
Microsoft 365OneDrive (đọc/ghi), SharePoint (đọc) — auto-connect✅ Hoàn chỉnh
MCP ConnectorsKết nối external tools qua Model Context Protocol✅ Hoàn chỉnh
JiraRead-only: search issues, get issue details✅ Hoàn chỉnh
Ext ConnectorsFramework CAD/CAE/MS365/Other✅ Hoàn chỉnh
+ Slide 8 / 13 +
+ +
+

Chức năng hiện tại (3/4) — Administration

+ + + + + + + + + + + + + +
Chức năngMô tảTrạng thái
Tài khoản & RBACAdmin / Sub-admin / User, import/export Excel✅ Hoàn chỉnh
GroupsNhóm tài khoản để phân quyền theo nhóm✅ Hoàn chỉnh
DashboardThống kê token usage & chi phí, biểu đồ spline✅ Hoàn chỉnh
Auto Model RoutingBenchmark model, tự động định tuyến (Auto/Manual/Off)✅ Hoàn chỉnh
Agent Security3 lớp bảo mật: prompt, attachment, command✅ Hoàn chỉnh
SandboxRisk-based: Direct/Integrity/AppContainer/Win Sandbox✅ Hoàn chỉnh
MonitoringOverview, Security, MCP, Logs, Agent Status✅ Hoàn chỉnh
Audit Logtool_call, permission, security_block, mcp_call✅ Hoàn chỉnh
Model PricingBảng giá model, tùy chỉnh USD/token✅ Hoàn chỉnh
+ Slide 9 / 13 +
+ +
+

Chức năng hiện tại (4/4) — Document & File

+ + + + + + + + + +
Chức năngMô tảTrạng thái
Doc ExtractTrích xuất text từ PDF/DOCX/XLSX/PPTX/images✅ Hoàn chỉnh
PPTX EditTạo và chỉnh sửa PowerPoint files✅ Hoàn chỉnh
XLSX WriteTạo Excel files với styling✅ Hoàn chỉnh
Image GenTạo ảnh bằng AI✅ Hoàn chỉnh
Link FetchFetch URL preview cho task attachments✅ Hoàn chỉnh
+
Tổng cộng: 30+ tính năng đã hoàn chỉnh, sẵn sàng dùng trong doanh nghiệp.
+
Doc Extract hỗ trợ PDF, DOCX, XLSX, PPTX, images. PPTX Edit tạo và chỉnh sửa slide với font/styling. XLSX Write tạo Excel có màu sắc, border.
+
AI tạo file trực tiếp — từ câu lệnh chat, AI sinh ra tài liệu/báo cáo/ảnh dùng ngay được.
+ Slide 10 / 13 +
+

Hướng dẫn build & chạy ứng dụng

+
+
+
Yêu cầu hệ thống
+
    +
  • Python 3.10+ (khuyến nghị 3.11/3.12)
  • +
  • OS: Windows 10/11, macOS, Linux
  • +
  • Network: cần internet để cài dependencies & gọi API AI
  • +
+
Cài & chạy
+
    +
  • pip install -r requirements.txt
  • +
  • python -m cowork_local hoặc python __main__.py
  • +
+
+
+
Cấu hình API key
+
    +
  • Settings (⚙) → chọn provider → nhập API key & base URL.
  • +
  • Hoặc đặt qua environment variables: OPENAI_API_KEY, OPENAI_BASE_URL…
  • +
+
Lưu ý
+
    +
  • Cấu hình lưu tại ~/.cowork_local/config.json
  • +
  • Một số package (như opendataloader-pdf) tự cài khi cần lần đầu.
  • +
  • Lỗi No module named cowork_local → chạy python __main__.py.
  • +
+
+
+ Slide 11 / 13 +
+ +
+

Kịch bản Demo

+
+
+
Demo 1 — File → AI → Báo cáo + OneDrive
+
    +
  • Mở Cowork Chat, đính kèm file báo cáo doanh thu (.xlsx/.pdf).
  • +
  • Gõ: phân tích số liệu, tạo báo cáo .xlsx có màu + viết .md lên OneDrive.
  • +
  • AI: đọc → phân tích → tạo Excel → upload text lên OneDrive → trả link.
  • +
+
Demo 1: OneDrive write chỉ hỗ trợ text files (.md, .txt). Demo 2: Teams webhook + Outlook desktop notification. Demo 3: Auto mode chạy toàn bộ workflow tự động.
+
+
+
Demo 2 — Schedule Task tự động
+
    +
  • Vào Schedule → tạo task, đặt lịch "8h sáng thứ 2 hàng tuần".
  • +
  • Nội dung: đọc file doanh thu, phân tích, tạo báo cáo .xlsx.
  • +
  • Bật working_days_only + skip_holidays.
  • +
  • Task tự chạy, kết quả lưu trong task artifacts.
  • +
+
+
+
Demo 3 — Co4E Workflow phân tích dự án
+
    +
  • Step 1 (Research): đọc code, phân tích kiến trúc.
  • +
  • Step 2 (Implement - parallel): 2 sub-agent cùng chạy (unit test + docs).
  • +
  • Step 3 (Join + Review): tổng hợp, kiểm tra chất lượng.
  • +
  • Chạy Auto mode → AI tự thực hiện từng bước.
  • +
+
+
+ Slide 12 / 13 +
+ +
+

Tóm tắt

+
+
+
Dễ dùng & Đa năng
+
    +
  • Giao diện chat, không cần code.
  • +
  • Chat, workflow, schedule, code, Structure Graph, skills.
  • +
+
+
+
Bảo mật & Tiết kiệm
+
    +
  • 3 lớp Agent Security + Sandbox risk-based.
  • +
  • Auto Model Routing, theo dõi chi phí.
  • +
+
+
+
Tích hợp & Quản trị
+
    +
  • MS365 (OneDrive/SharePoint), MCP, Jira.
  • +
  • RBAC, Dashboard, Audit Log, Groups.
  • +
+
+
+
Cowork-Local BamBOO — công cụ AI doanh nghiệp toàn diện: dễ dùng, bảo mật, tiết kiệm, tích hợp, quản trị, đa năng.
+
30+ tính năng đã hoàn chỉnh — sẵn sàng triển khai trong doanh nghiệp ngay hôm nay.
+ Slide 13 / 13 +
+ + + diff --git a/slides/cowork-local-bamboo/deck.pdf b/slides/cowork-local-bamboo/deck.pdf new file mode 100644 index 0000000..71b5091 Binary files /dev/null and b/slides/cowork-local-bamboo/deck.pdf differ diff --git a/slides/cowork-local-bamboo/deck.pptx b/slides/cowork-local-bamboo/deck.pptx new file mode 100644 index 0000000..367e6a9 Binary files /dev/null and b/slides/cowork-local-bamboo/deck.pptx differ diff --git a/tests/pytest-cache-files-kggsphad/CACHEDIR.TAG b/tests/pytest-cache-files-kggsphad/CACHEDIR.TAG new file mode 100644 index 0000000..e69de29 diff --git a/theme.py b/theme.py index 9768e70..391c5d5 100644 --- a/theme.py +++ b/theme.py @@ -1,295 +1,659 @@ -"""Qt style sheets giving the app a modern, dark design-tool look (deep -ocean-blue surfaces, large rounded corners, a deep-sea gradient accent, and -colorful pill badges) inspired by contemporary dashboard UIs.""" -from __future__ import annotations - -# Brand accent — deep sea blue palette with teal-cyan gradients. -ACCENT = "#0096C7" -ACCENT_HOVER = "#48CAE4" -ACCENT2 = "#0077B6" # deeper ocean blue for gradients -GRADIENT = f"qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 {ACCENT2}, stop:1 {ACCENT})" -GRADIENT_HOVER = f"qlineargradient(x1:0, y1:0, x2:1, y2:0, stop:0 #023E8A, stop:1 {ACCENT_HOVER})" - -_DARK = f""" -* {{ font-family: "Segoe UI", "Helvetica Neue", "Arial", "Yu Gothic UI", "Meiryo", sans-serif; font-size: 13px; }} -QMainWindow, QWidget {{ background: #0A1628; color: #E0F0FF; }} -QMainWindow::separator {{ background: #0A1628; width: 4px; height: 4px; }} -QStatusBar {{ background: #0A1628; color: #5C8DB8; border-top: 1px solid #132240; }} -QSplitter::handle {{ background: #0A1628; }} -/* Separate the nav rail (its own panel + divider) from the content area. */ -QWidget#navWrap {{ background: #0D1F35; border-right: 1px solid #17263f; }} -QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget {{ background: transparent; border: none; }} -QWidget#contentArea {{ background: #0A1628; }} -/* Compact icons app-wide (they looked oversized on scaled displays). */ -QPushButton, QToolButton, QComboBox, QTabBar {{ qproperty-iconSize: 14px 14px; }} -QTreeWidget#navrail {{ qproperty-iconSize: 16px 16px; }} - -/* Square the pane (tabs above stay rounded): a rounded pane lets its square - child pages poke past the corners — the "rectangle behind the rounded box". */ -QTabWidget::pane {{ background: #0D1F35; border: 1px solid #132240; border-radius: 0px; top: 2px; }} -QTabBar {{ background: transparent; }} -QTabBar::tab {{ - background: #111D32; color: #5C8DB8; padding: 9px 22px; margin: 0 6px 8px 0; - border-radius: 10px; border: 1px solid #132240; font-weight: 500; -}} -QTabBar::tab:selected {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; }} -QTabBar::tab:hover:!selected {{ background: #172A45; color: #B0D4F1; }} -/* Co4E flow "browser" tabs: sit FLUSH in their row (no floating bottom gap, so - the icon/label is vertically centred) and use the app's panel/accent surfaces - so the strip reads as part of the app. */ -QTabBar#flowTabs::tab {{ background: #0D1F35; color: #8FB2D4; border: 1px solid #17263f; - padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; border-radius: 8px; }} -QTabBar#flowTabs::tab:selected {{ background: {GRADIENT}; color: white; border: 1px solid transparent; }} -QTabBar#flowTabs::tab:hover:!selected {{ background: #172A45; color: #E0F0FF; }} -/* The "new flow" (+) button — styled as the last tab in the strip. */ -QPushButton#flowAddBtn {{ background: #0D1F35; color: #8FB2D4; border: 1px solid #17263f; - border-radius: 8px; padding: 6px 0; font-size: 15px; font-weight: bold; min-height: 22px; }} -QPushButton#flowAddBtn:hover {{ background: #172A45; color: #E0F0FF; }} -/* Co4E sidebar (Workflows/Agents/Skills): transparent icon tabs with a subtle - translucent selection + the normal text colour (matches the app's lists, - not a bright fill). */ -QTabBar#co4eSideTabs {{ qproperty-iconSize: 18px 18px; }} -QTabBar#co4eSideTabs::tab {{ background: transparent; color: #8FB2D4; border: none; - border-radius: 6px; padding: 5px; margin: 0 6px 0 0; }} -QTabBar#co4eSideTabs::tab:selected {{ background: rgba(0,150,199,0.28); color: #E0F0FF; }} -QTabBar#co4eSideTabs::tab:hover:!selected {{ background: #172A45; color: #B0D4F1; }} -/* Co4E canvas frame — match the app's other framed surfaces (not a faint hairline). */ -QGraphicsView#co4eCanvas {{ background: #0D1F35; border: 1px solid #1A2D4A; border-radius: 10px; }} - -QGroupBox {{ - background: #0D1F35; border: 1px solid #132240; border-radius: 18px; - margin-top: 16px; padding: 14px 10px 10px 10px; font-weight: 700; -}} -QGroupBox::title {{ - subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 2px; - padding: 0 6px; color: #5C8DB8; letter-spacing: 0.5px; -}} - -QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QTreeView, QListView, QTreeWidget, QListWidget {{ - background: #111D32; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; -}} -/* Scroll containers must NOT paint their own square panel behind rounded - children (that square is what shows as a "rectangle under the rounded box"). - The corner where scrollbars meet is squared off too — keep it transparent. */ -QScrollArea {{ background: transparent; border: none; }} -QAbstractScrollArea::corner {{ background: transparent; }} -QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus {{ border: 1px solid {ACCENT}; }} -QTreeView::item, QListView::item {{ padding: 3px 2px; border-radius: 6px; }} -QTreeView::item:hover, QListView::item:hover {{ background: #172A45; }} -QTreeView::item:selected, QListView::item:selected {{ background: rgba(0,150,199,0.28); color: #E0F0FF; }} -QHeaderView::section {{ background: #111D32; color: #5C8DB8; border: none; border-bottom: 1px solid #132240; padding: 6px; }} - -QPushButton {{ - background: #132240; color: #E0F0FF; border: 1px solid #1A2D4A; - border-radius: 10px; padding: 8px 16px; -}} -QPushButton:hover {{ background: #1A3050; border-color: #234070; }} -QPushButton:pressed {{ background: #111D32; }} -QPushButton:disabled {{ color: #3A5A78; background: #0F1A28; border-color: #132240; }} -QPushButton#primary {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; padding: 8px 18px; }} -QPushButton#primary:hover {{ background: {GRADIENT_HOVER}; }} -QPushButton#primary:disabled {{ background: #1A2D4A; color: #3A5A78; }} -QPushButton#danger {{ background: #E5484D; color: white; border: none; font-weight: 600; }} -QPushButton#danger:hover {{ background: #EF6368; }} -QPushButton#navMenuBtn {{ - background: transparent; border: none; border-radius: 8px; padding: 5px 6px; - font-weight: 700; font-size: 11px; letter-spacing: 1px; color: #5C8DB8; text-align: left; -}} -QPushButton#navMenuBtn:hover {{ background: #132240; color: #E0F0FF; }} -QPushButton#navMenuBtn:pressed {{ background: #111D32; }} - -QLabel#badge {{ background: #0A2A3A; color: #48CAE4; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeSuccess {{ background: #0A2A20; color: #48D9A0; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePurple {{ background: #1A1A3A; color: #9B8FF7; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePink {{ background: #2A1A30; color: #D980C0; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeWarn {{ background: #0A2A3A; color: #48CAE4; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#hint {{ color: #5C8DB8; }} -QLabel#warning {{ color: #48CAE4; font-weight: 600; }} - -QComboBox {{ background: #111D32; border: 1px solid #1A2D4A; border-radius: 10px; padding: 6px 10px; }} -QComboBox:hover {{ border-color: #234070; }} -QComboBox::drop-down {{ border: none; width: 22px; }} -QComboBox QAbstractItemView {{ - background: #111D32; border: 1px solid #1A2D4A; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; outline: none; -}} - -QScrollBar:vertical {{ background: transparent; width: 11px; margin: 2px; }} -QScrollBar::handle:vertical {{ background: #1A2D4A; border-radius: 5px; min-height: 28px; }} -QScrollBar::handle:vertical:hover {{ background: {ACCENT}; }} -QScrollBar:horizontal {{ background: transparent; height: 11px; margin: 2px; }} -QScrollBar::handle:horizontal {{ background: #1A2D4A; border-radius: 5px; min-width: 28px; }} -QScrollBar::handle:horizontal:hover {{ background: {ACCENT}; }} -QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; border: none; background: none; }} - -QCheckBox {{ spacing: 8px; }} -QCheckBox::indicator, QRadioButton::indicator {{ - width: 16px; height: 16px; background: #111D32; border: 1px solid #1A2D4A; border-radius: 5px; -}} -QRadioButton::indicator {{ border-radius: 9px; }} -QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }} -QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ - background: {GRADIENT}; border-color: {ACCENT}; -}} -QCheckBox::indicator:disabled {{ border-color: #132240; background: #0F1A28; }} -QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {{ - background: #1A3050; border-color: #234070; -}} - -QMenu {{ background: #111D32; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 10px; padding: 4px; }} -QMenu::item {{ padding: 6px 16px; border-radius: 6px; }} -QMenu::item:selected {{ background: {ACCENT}; color: white; }} - -QToolTip {{ background: #172A45; color: #E0F0FF; border: 1px solid #1A2D4A; border-radius: 6px; padding: 4px 8px; }} -""" - -_LIGHT = f""" -* {{ font-family: "Segoe UI", "Helvetica Neue", "Arial", "Yu Gothic UI", "Meiryo", sans-serif; font-size: 13px; }} -QMainWindow, QWidget {{ background: #E8F4FD; color: #1A2332; }} -QStatusBar {{ background: #E8F4FD; color: #5C8DB8; border-top: 1px solid #B8D4E8; }} -/* Separate the nav rail (its own panel + divider) from the content area. */ -QWidget#navWrap {{ background: #EDF5FB; border-right: 1px solid #C4DBEC; }} -QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget {{ background: transparent; border: none; }} -QWidget#contentArea {{ background: #E8F4FD; }} -/* Compact icons app-wide (they looked oversized on scaled displays). */ -QPushButton, QToolButton, QComboBox, QTabBar {{ qproperty-iconSize: 14px 14px; }} -QTreeWidget#navrail {{ qproperty-iconSize: 16px 16px; }} - -QTabWidget::pane {{ background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 0px; top: 2px; }} -QTabBar {{ background: transparent; }} -QTabBar::tab {{ - background: #D0E8F5; color: #3A6B8C; padding: 9px 22px; margin: 0 6px 8px 0; - border-radius: 10px; border: 1px solid #B8D4E8; font-weight: 500; -}} -QTabBar::tab:selected {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; }} -QTabBar::tab:hover:!selected {{ background: #B8D4E8; color: #1A2332; }} -/* Co4E flow "browser" tabs — light-theme counterpart (see dark block). */ -QTabBar#flowTabs::tab {{ background: #EDF5FB; color: #3A6B8C; border: 1px solid #C4DBEC; - padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; border-radius: 8px; }} -QTabBar#flowTabs::tab:selected {{ background: {GRADIENT}; color: white; border: 1px solid transparent; }} -QTabBar#flowTabs::tab:hover:!selected {{ background: #D0E8F5; color: #1A2332; }} -/* The "new flow" (+) button (light) — styled as the last tab in the strip. */ -QPushButton#flowAddBtn {{ background: #EDF5FB; color: #3A6B8C; border: 1px solid #C4DBEC; - border-radius: 8px; padding: 6px 0; font-size: 15px; font-weight: bold; min-height: 22px; }} -QPushButton#flowAddBtn:hover {{ background: #D0E8F5; color: #1A2332; }} -/* Co4E sidebar (light) — transparent tabs, translucent selection, dark text. */ -QTabBar#co4eSideTabs {{ qproperty-iconSize: 18px 18px; }} -QTabBar#co4eSideTabs::tab {{ background: transparent; color: #3A6B8C; border: none; - border-radius: 6px; padding: 5px; margin: 0 6px 0 0; }} -QTabBar#co4eSideTabs::tab:selected {{ background: rgba(0,150,199,0.20); color: #1A2332; }} -QTabBar#co4eSideTabs::tab:hover:!selected {{ background: #D0E8F5; color: #1A2332; }} -/* Co4E canvas frame (light). */ -QGraphicsView#co4eCanvas {{ background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 10px; }} - -QGroupBox {{ - background: #FFFFFF; border: 1px solid #B8D4E8; border-radius: 18px; - margin-top: 16px; padding: 14px 10px 10px 10px; font-weight: 700; -}} -QGroupBox::title {{ - subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 2px; - padding: 0 6px; color: #5C8DB8; letter-spacing: 0.5px; -}} - -QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QTreeView, QListView, QTreeWidget, QListWidget {{ - background: white; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; -}} -QScrollArea {{ background: transparent; border: none; }} -QAbstractScrollArea::corner {{ background: transparent; }} -QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus {{ border: 1px solid {ACCENT}; }} -QTreeView::item, QListView::item {{ padding: 3px 2px; border-radius: 6px; }} -QTreeView::item:hover, QListView::item:hover {{ background: #E0EFFA; }} -QTreeView::item:selected, QListView::item:selected {{ background: rgba(0,150,199,0.18); color: #1A2332; }} -QHeaderView::section {{ background: #E8F4FD; color: #5C8DB8; border: none; border-bottom: 1px solid #B8D4E8; padding: 6px; }} - -QPushButton {{ - background: white; color: #1A2332; border: 1px solid #C0D8EC; - border-radius: 10px; padding: 8px 16px; -}} -QPushButton:hover {{ background: #E0EFFA; }} -QPushButton:disabled {{ color: #8AA8C0; background: #F0F8FC; }} -QPushButton#primary {{ background: {GRADIENT}; color: white; border: none; font-weight: 700; padding: 8px 18px; }} -QPushButton#primary:hover {{ background: {GRADIENT_HOVER}; }} -QPushButton#danger {{ background: #E5484D; color: white; border: none; font-weight: 600; }} -QPushButton#danger:hover {{ background: #EF6368; }} -QPushButton#navMenuBtn {{ - background: transparent; border: none; border-radius: 8px; padding: 5px 6px; - font-weight: 700; font-size: 11px; letter-spacing: 1px; color: #5C8DB8; text-align: left; -}} -QPushButton#navMenuBtn:hover {{ background: #D0E8F5; color: #1A2332; }} -QPushButton#navMenuBtn:pressed {{ background: #B8D4E8; }} - -QLabel#badge {{ background: #D0ECF8; color: #0077B6; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeSuccess {{ background: #D0F5E8; color: #1B7A3D; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePurple {{ background: #E8E0FF; color: #6238C9; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgePink {{ background: #F8E0F0; color: #B93A85; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#badgeWarn {{ background: #D0ECF8; color: #0077B6; border-radius: 11px; padding: 2px 12px; font-weight: 600; }} -QLabel#hint {{ color: #5C8DB8; }} -QLabel#warning {{ color: #0077B6; font-weight: 600; }} - -QComboBox {{ background: white; border: 1px solid #C0D8EC; border-radius: 10px; padding: 6px 10px; }} -QComboBox::drop-down {{ border: none; width: 22px; }} -QComboBox QAbstractItemView {{ - background: white; border: 1px solid #C0D8EC; border-radius: 10px; - selection-background-color: {ACCENT}; selection-color: white; outline: none; -}} - -QScrollBar:vertical {{ background: transparent; width: 11px; margin: 2px; }} -QScrollBar::handle:vertical {{ background: #C0D8EC; border-radius: 5px; min-height: 28px; }} -QScrollBar::handle:vertical:hover {{ background: {ACCENT}; }} -QScrollBar:horizontal {{ background: transparent; height: 11px; margin: 2px; }} -QScrollBar::handle:horizontal {{ background: #C0D8EC; border-radius: 5px; min-width: 28px; }} -QScrollBar::handle:horizontal:hover {{ background: {ACCENT}; }} -QScrollBar::add-line, QScrollBar::sub-line {{ height: 0; width: 0; border: none; background: none; }} - -QCheckBox {{ spacing: 8px; }} -QCheckBox::indicator, QRadioButton::indicator {{ - width: 16px; height: 16px; background: white; border: 1px solid #C0D8EC; border-radius: 5px; -}} -QRadioButton::indicator {{ border-radius: 9px; }} -QCheckBox::indicator:hover, QRadioButton::indicator:hover {{ border-color: {ACCENT}; }} -QCheckBox::indicator:checked, QRadioButton::indicator:checked {{ - background: {GRADIENT}; border-color: {ACCENT}; -}} -QCheckBox::indicator:disabled {{ border-color: #D0E8F5; background: #F0F8FC; }} -QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled {{ - background: #B8D4E8; border-color: #8AB8D8; -}} - -QMenu {{ background: white; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 10px; padding: 4px; }} -QMenu::item {{ padding: 6px 16px; border-radius: 6px; }} -QMenu::item:selected {{ background: {ACCENT}; color: white; }} - -QToolTip {{ background: #FFFFFF; color: #1A2332; border: 1px solid #C0D8EC; border-radius: 6px; padding: 4px 8px; }} -""" - -# Node colors used by the Graph view (shared light/dark). -NODE_COLORS = { - "user": "#3B82F6", - "assistant": ACCENT, - "tool": "#8B5CF6", - "result": "#22A06B", - "error": "#E5484D", -} - - -def resolve_theme(theme: str) -> str: - """Resolve 'system' to 'dark'/'light' based on the OS color scheme.""" - if theme != "system": - return theme - try: - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QApplication - - app = QApplication.instance() - if app is not None: - scheme = app.styleHints().colorScheme() - return "light" if scheme == Qt.ColorScheme.Light else "dark" - except Exception: - pass - return "dark" - - -def stylesheet(theme: str) -> str: - return _LIGHT if resolve_theme(theme) == "light" else _DARK \ No newline at end of file +"""The app's visual system: semantic design tokens + one style sheet template. + +This replaces the previous mechanism, which was two hand-written Qt style +sheets (``_DARK`` / ``_LIGHT``) that duplicated each other and hard-coded ~105 +hex literals, with a single source of truth: + + Palette (tokens) -> _TEMPLATE (one QSS) -> stylesheet(theme) + +Rules of the system +------------------- +* **Nothing outside this module names a colour.** Widgets that paint with + ``QPainter`` (charts, canvases, syntax highlighters) call :func:`palette` and + read a token. Widgets that style themselves declaratively should instead be + given an ``objectName`` and styled in ``_TEMPLATE`` below. +* **Tokens are semantic, not literal.** ``danger``/``text_muted``/``code_string`` + — never ``blue``/``grey2``. Adding a theme means adding a :class:`Palette`, + not editing a style sheet. +* **No gradients, no glows.** The palette is Visual Studio Code's — "Dark + Modern" and "Light Modern", taken from the shipped theme JSON. Flat surfaces, + square-ish corners, one accent spent only on what the user acts on. Depth + comes from the surface ramp and hairline borders, not from colour. Note the + VS Code silhouette: the nav rail is *darker* than the content area, not + lighter. + +Contrast is held to WCAG AA (4.5:1) for body text and for text on filled +buttons. That is why ``accent`` and ``accent_solid`` are separate tokens: on a +dark background a blue readable *as text* is too light to carry white *as a +fill*, so each role gets the tint that passes. + +Four VS Code values fall below AA and are nudged just far enough to clear it — +dark line numbers (3.59:1), light faint text on the sidebar (4.28:1), light +green (4.33:1) and light amber (3.12:1). Each carries a comment naming the +original value, so the deviation is auditable rather than silent. +""" +from __future__ import annotations + +from dataclasses import dataclass, asdict +from string import Template + + +@dataclass(frozen=True) +class Palette: + """Every colour and shape value the interface is allowed to use.""" + + name: str + + # --- surfaces: a 4-step ramp from the window back to the frontmost layer. + bg: str # window / canvas backdrop + surface: str # panels, cards, group boxes (NOT the nav rail) + surface_raised: str # inputs, lists, trees — things you type or pick in + overlay: str # menus, tooltips, popups (floats above everything) + sunken: str # logs, code, terminals — things you read into + hover: str # hover wash on rows, tabs, ghost buttons + active: str # pressed / held state + + # The nav rail gets its own step rather than borrowing `surface`. It is a + # permanent region of the window, not a card floating on the page. + # + # Following VS Code, the rail is *darker* than the content area (dark) or a + # shade off white (light). The step is small on purpose — VS Code separates + # the rail with a border, not a big tonal jump — so `nav_border` is doing + # real work here and must stay visible. + nav_bg: str + nav_border: str + nav_hover: str + nav_selected: str + + # --- lines + border: str # default hairline + border_strong: str # hairline that must survive next to a filled surface + focus_ring: str # keyboard/typing focus + + # --- text + text: str + text_muted: str # secondary copy, captions, group-box titles + text_faint: str # metadata, timestamps, placeholder + text_disabled: str + on_accent: str # text drawn on top of a filled accent/status surface + + # --- accent: `accent` tints text & icons, `accent_solid` fills buttons. + accent: str + accent_solid: str + accent_solid_hover: str + accent_solid_active: str + accent_soft: str # translucent wash for selected rows (QSS only) + accent_soft_hover: str + accent_wash: str # the same tint pre-blended to a solid, for Qt rich + # text (bgcolor=, ) where alpha is ignored + + # --- status + success: str + success_soft: str + warning: str + warning_soft: str + danger: str + danger_solid: str + danger_solid_hover: str + danger_soft: str + info: str + info_soft: str + purple: str + purple_soft: str + pink: str + pink_soft: str + + # --- selection (text selection inside editors and inputs) + selection_bg: str + selection_fg: str + + # --- scrollbars + scroll_handle: str + scroll_handle_hover: str + + # --- code & terminal + code_bg: str + code_fg: str + code_gutter_bg: str + code_gutter_fg: str + code_selection: str + code_comment: str + code_keyword: str + code_type: str + code_func: str + code_attr: str + code_string: str + code_number: str + code_error: str + + # --- diff / inline change badges + diff_add_bg: str + diff_add_fg: str + diff_del_bg: str + diff_del_fg: str + + # --- charts + chart_grid: str + chart_label: str + + # --- conversation & graph node roles + role_user: str + role_assistant: str + role_tool: str + role_result: str + role_error: str + + # --- shape & type + radius_sm: int + radius: int + radius_lg: int + font_family: str + font_size: int + font_mono: str + + +_FONT = '"Segoe UI Variable Text", "Segoe UI", "Inter", "Helvetica Neue", "Yu Gothic UI", "Meiryo", sans-serif' +_MONO = '"Cascadia Code", "JetBrains Mono", "Consolas", "SF Mono", monospace' + + +DARK = Palette( + name="dark", + # ---- VS Code "Dark Modern" ---------------------------------------------- + # Values taken from the shipped theme JSON. Where VS Code's own choice falls + # below WCAG AA it is nudged just far enough to pass; each such value carries + # a note with VS Code's original and the measured ratio. + bg="#1F1F1F", # editor.background + surface="#252526", # panel / card + surface_raised="#313131", # input.background + overlay="#252526", # menus, tooltips + sunken="#181818", # logs, terminals — below the ramp + hover="#2A2D2E", # list.hoverBackground + active="#37373D", # list.inactiveSelectionBackground + # The sidebar is DARKER than the editor — that is the VS Code silhouette. + nav_bg="#181818", # sideBar.background + nav_border="#2B2B2B", # sideBar.border + nav_hover="#2A2D2E", + nav_selected="#04395E", # list.activeSelectionBackground + border="#2B2B2B", # panel.border + border_strong="#3C3C3C", # input.border + focus_ring="#0078D4", # focusBorder + text="#CCCCCC", # editor.foreground + text_muted="#9D9D9D", # descriptionForeground + text_faint="#9A9A9A", # lifted: #8B8B8B was 3.82:1 on input surfaces + text_disabled="#5A5A5A", + on_accent="#FFFFFF", + accent="#4DAAFC", # textLink.foreground — accent as TEXT + accent_solid="#0078D4", # button.background — accent as FILL + accent_solid_hover="#026EC1", + accent_solid_active="#005FB8", + accent_soft="rgba(0,120,212,0.22)", + accent_soft_hover="rgba(0,120,212,0.32)", + accent_wash="#12283C", # pre-blended: Qt rich text ignores alpha + success="#89D185", # gitDecoration added + success_soft="rgba(137,209,133,0.16)", + warning="#CCA700", # editorWarning + warning_soft="rgba(204,167,0,0.16)", + danger="#F76464", # editorError #F14C4C lifted (4.29:1 on panels) + danger_solid="#C4302B", + danger_solid_hover="#D9433C", + danger_soft="rgba(241,76,76,0.16)", + info="#4DAAFC", + info_soft="rgba(77,170,252,0.16)", + purple="#C586C0", # Dark+ syntax purple + purple_soft="rgba(197,134,192,0.16)", + pink="#D16D9E", + pink_soft="rgba(209,109,158,0.16)", + selection_bg="#264F78", # editor.selectionBackground + selection_fg="#FFFFFF", + scroll_handle="#4E4E4E", # scrollbarSlider + scroll_handle_hover="#5A5A5A", + code_bg="#1F1F1F", + code_fg="#CCCCCC", + code_gutter_bg="#1F1F1F", + # VS Code uses #6E7681 for line numbers — only 3.59:1. Lifted to clear AA. + code_gutter_fg="#858D97", + code_selection="#264F78", + code_comment="#6A9955", # ---- Dark+ syntax, unchanged -------------- + code_keyword="#569CD6", + code_type="#4EC9B0", + code_func="#DCDCAA", + code_attr="#9CDCFE", + code_string="#CE9178", + code_number="#B5CEA8", + code_error="#F44747", + diff_add_bg="#1B3A1B", # diffEditor inserted, pre-blended + diff_add_fg="#89D185", + diff_del_bg="#4B1818", # diffEditor removed, pre-blended + diff_del_fg="#F76464", + chart_grid="#2B2B2B", + chart_label="#9D9D9D", + role_user="#4DAAFC", + role_assistant="#4EC9B0", + role_tool="#C586C0", + role_result="#89D185", + role_error="#F14C4C", + radius_sm=3, # VS Code is squarer than the previous look + radius=4, + radius_lg=6, + font_family=_FONT, + font_size=13, + font_mono=_MONO, +) + + +LIGHT = Palette( + name="light", + # ---- VS Code "Light Modern" --------------------------------------------- + bg="#FFFFFF", # editor.background + surface="#F8F8F8", # sideBar / panel + surface_raised="#FFFFFF", # input.background + overlay="#FFFFFF", + sunken="#F3F3F3", + hover="#F2F2F2", # list.hoverBackground + active="#E8E8E8", # list.activeSelectionBackground + nav_bg="#F8F8F8", # sideBar.background + nav_border="#E5E5E5", # sideBar.border + nav_hover="#F2F2F2", + nav_selected="#E4E6F1", # active row, tinted toward the accent + border="#E5E5E5", + border_strong="#CECECE", # input.border + focus_ring="#005FB8", # focusBorder + text="#3B3B3B", # editor.foreground + text_muted="#616161", + # VS Code uses #767676 — 4.28:1 on the sidebar. Darkened to clear AA there. + text_faint="#6E6E6E", + text_disabled="#A0A0A0", + on_accent="#FFFFFF", + accent="#005FB8", # textLink / button + accent_solid="#005FB8", + accent_solid_hover="#0258A8", + accent_solid_active="#004C97", + accent_soft="rgba(0,95,184,0.10)", + accent_soft_hover="rgba(0,95,184,0.16)", + accent_wash="#E6EEF8", + # VS Code green #388A34 is 4.33:1 and amber #BF8803 only 3.12:1 — both lifted. + success="#317A2D", + success_soft="#DFF3DE", + warning="#8F6500", # VS Code #BF8803 = 3.12:1 + warning_soft="#FBF0D0", + danger="#CD3131", # editorError + danger_solid="#CD3131", + danger_solid_hover="#B82A2A", + danger_soft="#FDEFEF", # lightened so #CD3131 clears AA on it + info="#005FB8", + info_soft="#DDEBF9", + purple="#6F42C1", + purple_soft="#EDE7FA", + pink="#B3247E", + pink_soft="#FAE3F0", + selection_bg="#ADD6FF", # editor.selectionBackground + selection_fg="#000000", + scroll_handle="#C1C1C1", + scroll_handle_hover="#A6A6A6", + code_bg="#FFFFFF", + code_fg="#3B3B3B", + code_gutter_bg="#F8F8F8", + code_gutter_fg="#656C76", # VS Code #6E7681 = 4.33:1 on the gutter + code_selection="#ADD6FF", + code_comment="#008000", # ---- Light+ syntax ------------------------ + code_keyword="#0000FF", + code_type="#267F99", + code_func="#795E26", + code_attr="#E50000", + code_string="#A31515", + code_number="#098658", + code_error="#CD3131", + diff_add_bg="#DBF4DB", + diff_add_fg="#1E6F1A", + diff_del_bg="#FBE3E3", + diff_del_fg="#B82A2A", + chart_grid="#E5E5E5", + chart_label="#616161", + role_user="#005FB8", + role_assistant="#267F99", + role_tool="#6F42C1", + role_result="#317A2D", + role_error="#CD3131", + radius_sm=3, + radius=4, + radius_lg=6, + font_family=_FONT, + font_size=13, + font_mono=_MONO, +) + + +_PALETTES = {"dark": DARK, "light": LIGHT} + + +# --------------------------------------------------------------------------- +# The one style sheet. `$token` placeholders are filled from the Palette above; +# use `${token}px` where a unit follows the name. +# +# Read it as a cascade: reset -> shell -> surfaces -> controls -> chrome. +# --------------------------------------------------------------------------- +_TEMPLATE = Template(""" +/* ---- reset ------------------------------------------------------------ */ +* { font-family: $font_family; font-size: ${font_size}px; } +QWidget { background: $bg; color: $text; } +QMainWindow::separator { background: $border; width: 1px; height: 1px; } +QSplitter::handle { background: $border; } +QSplitter::handle:horizontal { width: 1px; } +QSplitter::handle:vertical { height: 1px; } +QSplitter::handle:hover { background: $border_strong; } +QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; } +QToolTip { + background: $overlay; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 5px 9px; +} + +/* Icons are drawn at text scale, not as decoration. */ +QPushButton, QToolButton, QComboBox, QTabBar { qproperty-iconSize: 15px 15px; } +QTreeWidget#navrail { qproperty-iconSize: 16px 16px; } + +/* ---- shell ------------------------------------------------------------ */ +QWidget#topbar { background: $bg; border: none; border-bottom: 1px solid $border; } +QLabel#brand { color: $text; font-size: 15px; font-weight: 700; background: transparent; } +QWidget#navWrap { background: $nav_bg; border-right: 1px solid $nav_border; } +QWidget#navWrap QTreeWidget, QWidget#navWrap QListWidget { background: transparent; border: none; } +QWidget#contentArea { background: $bg; } + +/* Rail rows sit on nav_bg, so they need their own hover/selected steps — the + generic ones are tuned against `bg` and wash out here. The active item also + carries a 2px accent marker, so which section you are in survives even at a + glance or for anyone who cannot separate the two greys. */ +QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item { + padding: 6px 4px; border-radius: ${radius}px; +} +QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover { + background: $nav_hover; +} +QWidget#navWrap QTreeWidget::item:selected, QWidget#navWrap QListWidget::item:selected { + background: $nav_selected; color: $text; + border-left: 2px solid $accent; font-weight: 600; +} + +/* ---- surfaces --------------------------------------------------------- */ +QGroupBox { + background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; + margin-top: 14px; padding: 16px 12px 12px 12px; font-weight: 600; +} +QGroupBox::title { + subcontrol-origin: margin; subcontrol-position: top left; left: 12px; top: 1px; + padding: 0 6px; color: $text_muted; font-size: 12px; font-weight: 600; +} +QFrame[frameShape="4"], QFrame[frameShape="5"] { background: $border; border: none; } +QScrollArea { background: transparent; border: none; } +QAbstractScrollArea::corner { background: transparent; } + +/* ---- tabs: an underline, not a pill. ------------------------------------- + The old pill tabs read as buttons and fought the real buttons for + attention. A 2px rule under the active label is quieter and unambiguous. */ +QTabWidget::pane { background: $bg; border: none; border-top: 1px solid $border; top: -1px; } +QTabBar { background: transparent; qproperty-drawBase: 0; } +QTabBar::tab { + background: transparent; color: $text_muted; padding: 9px 14px; margin: 0 2px 0 0; + border: none; border-bottom: 2px solid transparent; font-weight: 500; +} +QTabBar::tab:selected { color: $text; border-bottom: 2px solid $accent; font-weight: 600; } +QTabBar::tab:hover:!selected { color: $text; background: $hover; } + +/* Co4E flow strip — browser-style tabs, so these stay enclosed. */ +QTabBar#flowTabs::tab { + background: $surface; color: $text_muted; border: 1px solid $border; + border-radius: ${radius}px; padding: 6px 10px; margin: 0 3px 0 0; min-height: 22px; +} +QTabBar#flowTabs::tab:selected { background: $surface_raised; color: $text; border-color: $border_strong; } +QTabBar#flowTabs::tab:hover:!selected { background: $hover; color: $text; } +QPushButton#flowAddBtn { + background: transparent; color: $text_muted; border: 1px solid $border; + border-radius: ${radius}px; padding: 6px 0; font-size: 15px; min-height: 22px; +} +QPushButton#flowAddBtn:hover { background: $hover; color: $text; } + +/* Co4E icon sidebar — no chrome until it is the active one. */ +QTabBar#co4eSideTabs { qproperty-iconSize: 18px 18px; } +QTabBar#co4eSideTabs::tab { + background: transparent; color: $text_muted; border: none; + border-radius: ${radius}px; padding: 6px; margin: 0 4px 0 0; +} +QTabBar#co4eSideTabs::tab:selected { background: $accent_soft; color: $text; } +QTabBar#co4eSideTabs::tab:hover:!selected { background: $hover; color: $text; } +QGraphicsView#co4eCanvas { + background: $surface; border: 1px solid $border; border-radius: ${radius_lg}px; +} + +/* ---- text entry & item views ------------------------------------------ */ +QPlainTextEdit, QTextEdit, QTextBrowser, QLineEdit, QSpinBox, QDoubleSpinBox, +QTreeView, QListView, QTableView, QTreeWidget, QListWidget, QTableWidget { + background: $surface_raised; color: $text; border: 1px solid $border; + border-radius: ${radius}px; selection-background-color: $selection_bg; + selection-color: $selection_fg; +} +QPlainTextEdit, QTextEdit, QLineEdit, QSpinBox, QDoubleSpinBox { padding: 6px 8px; } +QPlainTextEdit:hover, QTextEdit:hover, QLineEdit:hover { border-color: $border_strong; } +QPlainTextEdit:focus, QTextEdit:focus, QLineEdit:focus, +QSpinBox:focus, QDoubleSpinBox:focus { border: 1px solid $focus_ring; } +QLineEdit:disabled, QPlainTextEdit:disabled, QTextEdit:disabled { + background: $surface; color: $text_disabled; +} + +QTreeView::item, QListView::item, QTableView::item { padding: 4px 3px; border-radius: ${radius_sm}px; } +QTreeView::item:hover, QListView::item:hover { background: $hover; } +QTreeView::item:selected, QListView::item:selected, QTableView::item:selected { + background: $accent_soft; color: $text; +} +QHeaderView::section { + background: $bg; color: $text_muted; border: none; + border-bottom: 1px solid $border; padding: 7px 6px; font-weight: 600; +} + +/* ---- buttons ----------------------------------------------------------- + Default is a quiet outline. Weight is reserved for #primary / #danger, so + at most one button per view should carry a fill. */ +QPushButton { + background: $surface_raised; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 7px 14px; font-weight: 500; +} +QPushButton:hover { background: $hover; border-color: $border_strong; } +QPushButton:pressed { background: $active; } +QPushButton:disabled { color: $text_disabled; background: $surface; border-color: $border; } +QPushButton:focus { border: 1px solid $focus_ring; } + +QPushButton#primary { + background: $accent_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600; +} +QPushButton#primary:hover { background: $accent_solid_hover; } +QPushButton#primary:pressed { background: $accent_solid_active; } +QPushButton#primary:disabled { background: $surface; color: $text_disabled; border-color: $border; } + +QPushButton#danger { + background: $danger_solid; color: $on_accent; border: 1px solid transparent; font-weight: 600; +} +QPushButton#danger:hover { background: $danger_solid_hover; } +QPushButton#danger:disabled { background: $surface; color: $text_disabled; border-color: $border; } + +/* Ghost buttons: nav section headers and icon-only chrome. */ +QPushButton#navMenuBtn { + background: transparent; border: none; border-radius: ${radius}px; padding: 5px 6px; + font-weight: 600; font-size: 11px; letter-spacing: 0.6px; color: $text_faint; text-align: left; +} +QPushButton#navMenuBtn:hover { background: $hover; color: $text; } +QPushButton#navMenuBtn:pressed { background: $active; } + +QToolButton { + background: transparent; color: $text_muted; border: none; + border-radius: ${radius}px; padding: 5px; +} +QToolButton:hover { background: $hover; color: $text; } +QToolButton:pressed { background: $active; } +QToolButton::menu-indicator { image: none; } + +/* ---- pickers ----------------------------------------------------------- */ +QComboBox { + background: $surface_raised; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 6px 10px; +} +QComboBox:hover { background: $hover; } +QComboBox:focus { border-color: $focus_ring; } +QComboBox:disabled { color: $text_disabled; background: $surface; border-color: $border; } +QComboBox::drop-down { border: none; width: 20px; } +QComboBox QAbstractItemView { + background: $overlay; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 4px; outline: none; + selection-background-color: $accent_soft; selection-color: $text; +} + +QMenu { background: $overlay; color: $text; border: 1px solid $border_strong; + border-radius: ${radius}px; padding: 4px; } +QMenu::item { padding: 6px 14px; border-radius: ${radius_sm}px; } +QMenu::item:selected { background: $accent_soft; color: $text; } +QMenu::item:disabled { color: $text_disabled; } +QMenu::separator { height: 1px; background: $border; margin: 4px 6px; } + +QMenuBar { background: $bg; color: $text; border-bottom: 1px solid $border; } +QMenuBar::item { padding: 5px 10px; border-radius: ${radius_sm}px; background: transparent; } +QMenuBar::item:selected { background: $hover; } + +/* ---- toggles ----------------------------------------------------------- */ +QCheckBox, QRadioButton { spacing: 8px; background: transparent; } +QCheckBox::indicator, QRadioButton::indicator { + width: 16px; height: 16px; background: $surface_raised; + border: 1px solid $border_strong; border-radius: ${radius_sm}px; +} +QRadioButton::indicator { border-radius: 9px; } +QCheckBox::indicator:hover, QRadioButton::indicator:hover { border-color: $accent; } +QCheckBox::indicator:checked, QRadioButton::indicator:checked { + background: $accent_solid; border-color: $accent_solid; +} +QCheckBox::indicator:disabled, QRadioButton::indicator:disabled { + background: $surface; border-color: $border; +} +QCheckBox::indicator:checked:disabled, QRadioButton::indicator:checked:disabled { + background: $border_strong; border-color: $border_strong; +} + +QSlider::groove:horizontal { height: 4px; background: $border; border-radius: 2px; } +QSlider::sub-page:horizontal { background: $accent_solid; border-radius: 2px; } +QSlider::handle:horizontal { + width: 14px; height: 14px; margin: -6px 0; border-radius: 7px; + background: $surface_raised; border: 1px solid $border_strong; +} +QSlider::handle:horizontal:hover { border-color: $accent; } + +QProgressBar { + background: $surface; border: none; border-radius: 3px; + height: 6px; text-align: center; color: $text_muted; +} +QProgressBar::chunk { background: $accent_solid; border-radius: 3px; } + +/* ---- scrollbars: overlay-thin, no arrows. ------------------------------ */ +QScrollBar:vertical { background: transparent; width: 10px; margin: 2px; } +QScrollBar::handle:vertical { background: $scroll_handle; border-radius: 4px; min-height: 28px; } +QScrollBar::handle:vertical:hover { background: $scroll_handle_hover; } +QScrollBar:horizontal { background: transparent; height: 10px; margin: 2px; } +QScrollBar::handle:horizontal { background: $scroll_handle; border-radius: 4px; min-width: 28px; } +QScrollBar::handle:horizontal:hover { background: $scroll_handle_hover; } +QScrollBar::add-line, QScrollBar::sub-line { height: 0; width: 0; border: none; background: none; } +QScrollBar::add-page, QScrollBar::sub-page { background: none; } + +/* ---- badges & inline text tones --------------------------------------- + One shape, six tones. Pick by meaning: badgeSuccess for a finished run, + badgeDanger for a failed one — not by which colour looks nice. */ +QLabel#badge, QLabel#badgeSuccess, QLabel#badgeWarn, QLabel#badgeDanger, +QLabel#badgePurple, QLabel#badgePink { + border-radius: ${radius_sm}px; padding: 2px 8px; font-size: 12px; font-weight: 600; +} +QLabel#badge { background: $info_soft; color: $info; } +QLabel#badgeSuccess { background: $success_soft; color: $success; } +QLabel#badgeWarn { background: $warning_soft; color: $warning; } +QLabel#badgeDanger { background: $danger_soft; color: $danger; } +QLabel#badgePurple { background: $purple_soft; color: $purple; } +QLabel#badgePink { background: $pink_soft; color: $pink; } + +QLabel { background: transparent; } +QLabel#hint { color: $text_muted; } +QLabel#faint { color: $text_faint; } +QLabel#warning { color: $warning; font-weight: 600; } +QLabel#error { color: $danger; font-weight: 600; } +QLabel#success { color: $success; font-weight: 600; } +QLabel#sectionTitle { color: $text; font-size: 15px; font-weight: 600; } + +/* ---- code, terminals & logs ------------------------------------------- + These read as "sunken" surfaces: the eye goes in, not across. */ +QPlainTextEdit#codeEditor, QPlainTextEdit#termOutput, QPlainTextEdit#logView { + background: $code_bg; color: $code_fg; border: none; + font-family: $font_mono; selection-background-color: $code_selection; +} +QLineEdit#termInput { + background: $code_bg; color: $code_fg; border: none; border-top: 1px solid $border; + font-family: $font_mono; border-radius: 0; padding: 7px 10px; +} +QLineEdit#termInput:focus { border-top-color: $accent; } + +/* The help-agent dock styles itself from these same tokens — it is a floating + overlay that re-applies on every theme switch. See ui/help_agent_widget.py. */ +""") + + +def resolve_theme(theme: str) -> str: + """Resolve ``'system'`` to ``'dark'``/``'light'`` from the OS colour scheme.""" + if theme in _PALETTES: + return theme + try: + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication + + app = QApplication.instance() + if app is not None: + scheme = app.styleHints().colorScheme() + return "light" if scheme == Qt.ColorScheme.Light else "dark" + except Exception: + pass + return "dark" + + +def palette(theme: str) -> Palette: + """The token set for ``theme``. Painting code reads its colours from here.""" + return _PALETTES[resolve_theme(theme)] + + +# The theme the running app is currently showing. Painting code (paintEvent, +# QSyntaxHighlighter, canvas items) reads it via current_palette() instead of +# re-reading config.json — that used to cost a file open per repaint. +_active_theme = "dark" + + +def set_active_theme(theme: str) -> str: + """Record the theme the app just applied. Call this next to every + ``QApplication.setStyleSheet(stylesheet(...))``. Returns the resolved name.""" + global _active_theme + _active_theme = resolve_theme(theme) + return _active_theme + + +def current_theme() -> str: + """The resolved theme ('dark'/'light') the app is showing right now.""" + return _active_theme + + +def current_palette() -> Palette: + """Tokens for the theme the app is showing right now.""" + return _PALETTES[_active_theme] + + +def stylesheet(theme: str) -> str: + """The application-wide Qt style sheet for ``theme``.""" + return _TEMPLATE.substitute(asdict(palette(theme))) + + +def role_colors(theme: str) -> dict[str, str]: + """Conversation/graph node colours keyed by role.""" + p = palette(theme) + return { + "user": p.role_user, + "assistant": p.role_assistant, + "tool": p.role_tool, + "result": p.role_result, + "error": p.role_error, + } diff --git a/tools/build_audit_page.py b/tools/build_audit_page.py new file mode 100644 index 0000000..3bc0010 --- /dev/null +++ b/tools/build_audit_page.py @@ -0,0 +1,1304 @@ +"""Generate docs/ui-audit.html — the UI/UX audit page. + +Reads docs/screens/manifest.json (produced by tools/capture_screens.py) and +emits one section per captured screen: the CURRENT screenshot on top, the +PROPOSED wireframe below, plus the problems found and what changes. + +Driven by the manifest so a screen can never be silently dropped: anything in +the manifest without an entry in ANALYSIS still gets a section, flagged as +"chưa phân tích". + +Run: python tools/build_audit_page.py +""" +from __future__ import annotations + +import base64 +import json +import sys +from datetime import datetime +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +DOCS = REPO / "docs" +MANIFEST = DOCS / "screens" / "manifest.json" +OUT = DOCS / "ui-audit.html" + +# Screenshots are inlined as data: URIs so the page is ONE self-contained file — +# copy it anywhere and the images travel with it. `--external` opts out, leaving +# the images as `screens/*.png` next to a much smaller HTML. +STANDALONE = "--external" not in sys.argv + +# -------------------------------------------------------------------------- +# Per-screen analysis. `wf` is a wireframe of the PROPOSED layout, built from +# the tiny class vocabulary defined in CSS below (.r=row, .c=col, .b=box …). +# -------------------------------------------------------------------------- + +# Wireframes carry the SAME demo content as the screenshots above them, so the +# two can be compared like-for-like instead of "real app vs empty boxes". +# +# RECENTS is SCOPED TO THE ACTIVE PROJECT, matching today's behaviour: history is +# stored inside the project's own folder (config.py:590 → workspace_tab.py:453) +# and the sidebar groups threads per project (sidebar.py:193). A flat, global +# recents list would silently drop both — a regression, not a simplification. +RECENTS = ["📌 Gom số liệu doanh thu", "Dựng slide trình bày Q3"] + + +def rail(active: str = "", project: str = "Báo cáo tài chính Q3") -> str: + """The proposed flat sidebar, with `active` highlighted.""" + items = ["Project", "Cowork", "Co4E", "Folder", "GraphRAG", "Schedule Task"] + rows = "".join( + f'
{n}
' for n in items) + recents = "".join(f'
{t}
' for t in RECENTS) + return ( + '
' + # The app already collapses the rail to icons only (150px ↔ 54px, + # app.py:409). Keep the control and keep it where it is. + '' + f'
📁 {project}▾
' + '
+ Đoạn chat mới
' + f'{rows}' + '
' + '
RECENTS
' + f'
📁 {project}
' + f'{recents}' + '
Tất cả project…
' + '
' + '
' + '
Dashboard
Monitoring
' + '
👤 local · Ollama ▾
' + '
') + + +def rail_collapsed() -> str: + """The rail after the MENU button folds it to icons only (54px).""" + icons = ["▣", "▤", "◫", "⌥", "◈", "▦"] + rows = "".join(f'
{g}
' + for i, g in enumerate(icons)) + return ('
' + '' + '
+
' + f'{rows}
' + '
◔
◕
' + '
👤
') + + +def projbar(name: str = "") -> str: + """Deprecated: the project picker moved into the sidebar (see `rail`). + + It scopes ctx.active_project_id — global app state (state.py:60) — so a bar + inside each screen's content area wrongly implied it was per-screen, and left + it far from the "+ Đoạn chat mới" button it governs. + """ + return "" + + +def li(text: str, sub: str = "", *, on: bool = False) -> str: + """One row in a list pane.""" + s = f'{sub}' if sub else "" + return f'
{text}{s}
' + + +def card(title: str, meta: str) -> str: + """One Kanban card.""" + return f'
{title}{meta}
' + + +# -------------------------------------------------------------------------- +# What each screen IS. `d` = one-paragraph purpose; `r` = the regions visible in +# the screenshot, left→right / top→bottom, so a reader can map the picture. +# -------------------------------------------------------------------------- +DESCRIPTIONS: dict[str, dict] = { + "dashboard": {"d": "Token đã tiêu và chi phí quy ra tiền, theo kỳ.", + "r": [("Header", "kỳ · granularity · metric · tiền tệ"), + ("6 thẻ", "Total · In · Out · Cache · Cost · Ngân sách"), + ("Biểu đồ", "spline + đường so sánh kỳ trước"), ("Thói quen", "top task tốn token")]}, + "schedule-kanban": {"d": "Kanban các tác vụ hẹn giờ. Bộ lập lịch chạy nền dù màn này đóng.", + "r": [("Lane", "một cột mỗi trạng thái"), + ("Thẻ", "kéo đổi lane · double-click sửa · chuột phải: Chạy ngay / Lịch sử")]}, + "schedule-calendar": {"d": "Cùng dữ liệu Kanban, xếp theo ngày.", + "r": [("Ô ngày", "nút + tạo task lúc 09:00 ngày đó")]}, + "workspace-project": {"d": "Khai báo project — đơn vị gom nhóm của app. Mỗi project có sandbox riêng " + "và Instructions chèn vào mọi chat. Đây là bộ chọn project duy nhất.", + "r": [("Trái", "danh sách project — chỉ hiện ở tab này"), + ("Phải", "Tên · Mô tả · Instructions · thư mục")]}, + "workspace-cowork": {"d": "Chat với agent. Agent đọc/ghi tệp trong sandbox, chạy lệnh, gọi MCP.", + "r": [("Lịch sử", "chat gom theo project; nhãn đậm = tên project, chỉ là nhãn"), + ("Hội thoại", "bong bóng theo trục thời gian"), ("Files", "tệp đầu ra, gập được"), + ("Composer", "Enter gửi · /skill · /agent"), + ("Hàng dưới", "thư mục sandbox (chỗ thứ 2 lộ project) · model · Định tuyến · Tự chạy")]}, + "workspace-co4e": {"d": "Xưởng dựng workflow node-graph. Lưu toàn cục, không theo project.", + "r": [("Sidebar", "3 tab icon: Workflows · Agents · Skills — kéo thả được"), + ("Dải tab", "Flow Status ghim + mỗi workflow một tab"), ("Canvas", "node và cạnh"), + ("Phải", "cấu hình bước đang chọn")]}, + "workspace-folder": {"d": "Duyệt tệp + nhờ AI sửa. AI không ghi đè — đề xuất diff, bấm Apply mới ghi.", + "r": [("Cây trái", "hệ thống tệp thật"), ("Viewer", "code / HTML / PDF / ảnh / bảng tính"), + ("Panel AI", "yêu cầu → plan → diff → Apply"), ("Terminal", "shell thật, không qua sandbox")]}, + "workspace-graphrag": {"d": "Đồ thị tri thức về cấu trúc mã/tài liệu + agent hỏi đáp trên đó.", + "r": [("Đồ thị", "node theo loại, cạnh có nhãn quan hệ"), ("Phải", "hỏi đáp dựa trên đồ thị")]}, + "monitoring-tổng-quan": {"d": "Chi phí, tài nguyên máy, sandbox, nhật ký gần đây.", + "r": [("Trái", "Token & chi phí · Hoạt động · Tài nguyên · Bảng giá model"), + ("Phải", "Sandbox · Quyền · Audit log")]}, + "monitoring-sự-kiện-bảo-mật": {"d": "Nhật ký lần agent chạm thứ nhạy cảm: lệnh bị chặn, truy cập ngoài sandbox.", + "r": [("Ô lọc", "có nút ✨ biến câu hỏi thành từ khoá")]}, + "monitoring-lịch-sử-gọi-mcp": {"d": "Mọi lần agent gọi MCP server ngoài.", + "r": [("Bảng", "không có ô lọc như 2 màn log kia — khác biệt không chủ đích")]}, + "monitoring-nhật-ký-hành-động": {"d": "Nhật ký cấp ứng dụng: ai đổi cấu hình, ai chạy task.", "r": []}, + "monitoring-trạng-thái-agent": {"d": "Agent nào đang bật và nguồn định nghĩa.", "r": []}, + "monitoring-agents-admin": {"d": "Quản trị agent hệ thống. Cũng là nơi chọn model cho robot trợ giúp.", + "r": [("Nút Kiểm tra", "probe provider thật")]}, + "monitoring-công-cụ": {"d": "Bật/tắt công cụ dựng sẵn và khai báo kết nối ngoài. " + "Màn duy nhất còn hiện dải tab bên trong.", + "r": [("Tool", "công cụ dựng sẵn + tự kiểm tra Internet"), ("Connector", "MCP · REST · MS365 · Jira")]}, + "monitoring-icon": {"d": "Thư viện icon, dùng lại khi đặt icon cho agent Co4E.", "r": []}, + "dialog-settings": {"d": "Thiết lập toàn app. Cuộn dọc, không mục lục.", + "r": [("5 nhóm", "Ngôn ngữ · Provider · Bảo mật (khoá mật khẩu) · Tham số · Routing")]}, + "dialog-task-editor": {"d": "Khai báo tác vụ hẹn giờ: nội dung, lịch lặp, phụ thuộc, thông báo.", + "r": [("5 nhóm", "Cơ bản · Lịch · Đầu vào · Phụ thuộc · Thực thi")]}, + "dialog-skills": {"d": "Quản lý skill — khối hướng dẫn tái dùng, gõ /skill để chèn.", + "r": [("Ô tick", "chính là bật/tắt skill")]}, + "dialog-skill-edit": {"d": "Soạn skill: tên, mô tả, hướng dẫn.", + "r": [("✨", "sinh hướng dẫn từ mô tả ngắn")]}, + "dialog-file-edit": {"d": "Xem và nhờ AI sửa tệp, mở từ panel Files trong chat.", + "r": [("Tệp nhị phân", "trích văn bản, chỉ đọc"), ("Lưu", "tạo .bak trước khi ghi")]}, + "dialog-co4e-agent": {"d": "Định nghĩa agent Co4E: tính cách, quyền, model, skill.", "r": []}, + "dialog-ext-connector": {"d": "Khai báo kết nối ngoài, 2 chế độ.", + "r": [("MCP (stdio)", "lệnh + tham số"), ("REST", "URL · key · header"), ("Test", "thử kết nối thật")]}, + "dialog-permission": {"d": "Chốt chặn cuối trước khi agent làm việc có hậu quả. " + "Bật Tự chạy thì bỏ qua bước này.", + "r": [("Xem trước", "lệnh sắp chạy hoặc diff sắp ghi")]}, + "dialog-agent-edit": {"d": "Soạn agent hệ thống: gắn vào chức năng nào, provider/model gì.", "r": []}, + "dialog-login": {"d": "Màn đăng nhập — đã dựng xong nhưng không nơi nào gọi. " + "App khởi động thẳng với user \“local\”, quyền admin.", + "r": [("3 trang", "Khởi tạo · Đăng nhập · Offline")]}, + "overlay-help-panel": {"d": "Robot trợ giúp nổi, có mặt trên mọi màn. Cố tình không có công cụ.", + "r": [("3 trạng thái", "tab mép → huy hiệu → panel chat")]}, +} + + +ANALYSIS: dict[str, dict] = { + "workspace-project": { + "problems": [ + "Pane trái đổi danh tính theo tab (workspace_tab.py:310-338): " + "Project → danh sách project, Cowork → History, còn lại → trống.", + "History chỉ tới được từ tab Cowork.", + "Bộ chọn project chỉ có ở tab Project.", + "2/3 chiều cao dưới là khoảng trống chết.", + "Header “Workspace — Projects” hiện ở mọi sub-tab.", + ], + "changes": [ + "History lên sidebar thành RECENTS, luôn thấy.", + "Thanh chọn project ở đầu trang, dùng chung mọi màn.", + "Pane trái cố định, không đổi danh tính.", + "Header đổi theo màn.", + ], + "wf": rail("Project") + ( + '
' + + projbar() + + '
Quản lý project
' + '
+ Project mới
' + '
' + '
' + '
PROJECT‹
' + + li("Trạm sạc EV — Cổng vận hành", "6 đoạn chat · 4 task") + + li("Báo cáo tài chính Q3", "2 đoạn chat · 3 task", on=True) + + li("Cổng tra cứu tài liệu ISO", "1 đoạn chat · 1 task") + + '
' + '
Tên
' + '
Báo cáo tài chính Q3
' + '
Mô tả
' + '
Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide.
' + '
Instructions
' + '
Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.
' + 'Mọi con số phải truy được về file nguồn.
' + '
Thư mục làm việc
' + '
…\\workspaces\\bao-cao-tai-chinh-q3
' + '
Đổi
Mở
' + '
' + '
Lưu project
' + '
'), + }, + "workspace-cowork": { + "problems": [ + "Hàng dưới composer nhồi 5 control + usage/cost + nút thư mục " + "(chat_panel.py:142-181).", + "Không có lối tắt “chat mới” — phải chọn project → mở nav → Cowork.", + "Màn duy nhất thấy được History.", + "Ba pane bóp vùng đọc hội thoại còn chưa tới 60% bề ngang.", + "Biết project nào, nhưng không đổi được. Project hiện ở 2 chỗ (nhãn nhóm Lịch sử, " + "nhãn thư mục đáy) — cả hai chỉ là nhãn. Đổi phải quay về tab Project " + "(workspace_tab.py:323).", + ], + "changes": [ + "Bộ chọn project lên sidebar — nó là trạng thái toàn cục " + "(ctx.active_project_id), không phải của riêng màn nào.", + "Bộ chọn project + nút “+ Đoạn chat mới” đặt cạnh nhau ở đầu sidebar: " + "chọn project rồi bấm, không rời màn. Nút cũ trên toolbar giữ nguyên.", + "History lên sidebar, vẫn gom theo project + mục “Tất cả project…”.", + "Usage/cost xuống thanh trạng thái; vùng gõ chỉ còn nhập · đính kèm · gửi.", + ], + "wf": rail("Cowork") + ( + '
' + + projbar() + + '
Gom số liệu doanh thu
' + '
qwen2.5-coder
' + '
Skills
' + '
Cuộc trò chuyện mới
' + # Files pane kept — the app has it, collapsible, and it is where + # "Xem & sửa bằng AI" is reached from. + '
' + '
Có 6 file Excel trong thư mục input, gom lại thành 1 bảng ' + 'tổng hợp giúp mình.
' + '
Đã đọc cả 6 file. Lưu ý: PB_Marketing.xlsx để cột “Doanh thu” ' + 'ở cột F thay vì D và có 3 dòng trống ở cuối.

' + 'Mình đã chuẩn hoá và xuất tonghop_q3.xlsx — 1.284 dòng, tổng 42.7 tỷ VND.
' + '
' + '
TỆP ĐẦU RA (3)' + '›
' + + li("tonghop_q3.xlsx") + li("BaoCao_Q3.pptx") + li("README.md") + + '
' + '
Nhập yêu cầu… (Enter để gửi)
' + '
📎
Gửi
' + '
Agent: qwen2.5-coder · Định tuyến: Tắt  ·  ' + '↓292.8K ↑102.7K · $0.31  ·  📁 bao-cao-tai-chinh-q3
' + '
'), + }, + "workspace-co4e": { + "problems": [ + "Bốn lớp điều hướng chồng nhau: nav → tab icon sidebar → dải tab flow → panel phải.", + "Dải tab flow lặp lại danh sách Workflows ngay bên trái.", + "Panel cấu hình 11 trường dọc, phải cuộn.", + ], + "changes": [ + "Bỏ dải tab flow; chọn workflow từ danh sách trái.", + "3 tab icon → 3 mục có nhãn cùng danh sách.", + "Còn 2 lớp: chọn trái → sửa phải.", + ], + "wf": rail("Co4E") + ( + '
' + + projbar() + + '
Quy trình phát triển tính năng
' + '
+ Bước
Auto ▾
' + '
▷ Chạy
' + '
' + '
' + '
WORKFLOWS‹
' + + li("Quy trình phát triển tính năng", "5 bước · đã lưu", on=True) + + li("Rà soát bảo mật định kỳ", "2 bước · đã lưu") + + li("Dựng báo cáo từ Excel", "3 bước · đã lưu") + + '
AGENTS (5)
' + + li("Phân tích yêu cầu", "ANALYST") + li("Thiết kế giải pháp", "ARCHITECT") + + li("Lập trình viên", "CODER") + li("Kiểm thử", "TESTER") + + li("Soạn tài liệu", "WRITER") + + '
SKILLS (5)
' + + li("Rà soát bảo mật · Viết test trước · Chuẩn hoá Excel · …") + + '
LẦN CHẠY (6)
' + + li("✓ Quy trình phát triển", "5/5 · 08-08 15:32") + + li("✕ Rà soát bảo mật", "3/5 · 08-06 16:32") + + li("■ Dựng báo cáo từ Excel", "1/5 · 08-04 18:32") + + '
' + '
' + '
Phân tích yêu cầu
→
' + '
Thiết kế
→
' + '
Lập trình viên
→
' + '
Kiểm thử
' + '
CẤU HÌNH BƯỚC›
' + '
▾ Cơ bảnLập trình viên · CODER
' + '
Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.
' + '
▸ Model & quyềnqwen2.5-coder · full
' + '
▸ Skills & tệpViết test trước
' + '
▸ Agent song songchưa có
' + '
' + '
'), + }, + "workspace-folder": { + "problems": [ + "Năm vùng cùng lúc: path · cây · viewer · panel AI · terminal.", + "Hàng nút viewer trộn 5 chức năng khác loại.", + ], + "changes": [ + "Path bar gộp vào tiêu đề.", + "Panel AI thành lớp phủ phải; terminal xuống đáy dạng thanh mỏng.", + ], + "wf": rail("Folder", "Trạm sạc EV — Cổng vận hành") + ( + '
' + + projbar("Trạm sạc EV — Cổng vận hành") + + '
Thư mục
' + '
…\\workspaces\\tram-sac-ev
' + '
Sửa
✨ AI
Lưu
' + '
' + '
' + + li("📁 src") + li(" 📄 main.py") + li(" 📁 billing") + + li("  📄 session.py", on=True) + li("📁 tests") + + li(" 📄 test_stations.py") + li("📄 README.md") + + '
' + '
' + '# billing/session.py
' + 'def close_session(sid):
' + '  s = repo.get(sid)
' + '  s.ended_at = None' + ' # ← lỗi tính tiền
' + '  return bill(s)
' + '
✨ AI SỬA TỆP›
' + '
Sửa lỗi tính dư tiền khi phiên bị ngắt đột ngột.
' + '
Lấy mốc heartbeat cuối làm ended_at.
' + '
- s.ended_at = None
' + '+ s.ended_at = last_heartbeat(sid)
' + '
' + '
Bỏ
Áp dụng
' + '
▸ Terminal — bật khi cần
'), + }, + "workspace-graphrag": { + "problems": [ + "Hai hàng toolbar riêng biệt — nên là một.", + "Nút Messages/Graph đổi hẳn nội dung pane nhưng trông như nút thường.", + ], + "changes": [ + "Gộp hai hàng toolbar thành một.", + "Messages/Graph thành cặp tab rõ ràng phía trên pane trái.", + ], + "wf": rail("GraphRAG", "Cổng tra cứu tài liệu ISO") + ( + '
' + + projbar("Cổng tra cứu tài liệu ISO") + + '
GraphRAG
' + '
…\\workspaces\\cong-tra-cuu-iso
' + '
Quét
Xuất PNG
' + '
' + '
Đồ thị
Tin nhắn
' + '
1.902 node · 3.418 cạnh
' + '
' + '
ISO 9001
→
' + '
Điều 7.5
→
' + '
Hồ sơ
' + '
HỎI ĐÁP TRÊN ĐỒ THỊ›
' + '
Điều khoản nào nói về kiểm soát hồ sơ?
' + '
Điều 7.5.3 — Kiểm soát thông tin dạng văn bản. ' + 'Có 12 tài liệu trùng số hiệu, xem trung_lap.md.
' + '
' + '
Đặt câu hỏi…
' + '
Hỏi
'), + }, + "dashboard": { + "problems": [ + "Tám control trên một hàng header.", + "Trùng Monitoring ▸ Tổng quan: cùng StatCard + BudgetCard.", + "Sáu thẻ số bằng nhau — không thấy đâu là chỉ số chính.", + ], + "changes": [ + "Header tách 2 hàng: thời gian / bộ lọc.", + "Nâng Chi phí làm thẻ chính, 4 thẻ còn lại phụ.", + ], + "wf": rail() + ( + '
' + '
Dashboard
' + '
◀
08/03 – 08/09
▶
' + '
Theo tuần ▾
Chi phí ▾
' + '
USD ▾
⟳
' + '
$0.31Tổng chi phí · 57 lượt
' + '
' + '
395.4KTổng token
' + '
292.8KInput
' + '
102.7KOutput
' + '
108.9KCache
' + '
' + '
' + '
Tốn nhiều nhất: Dựng slide trình bày — 105.4K (26%)
' + '
'), + }, + "schedule-kanban": { + "problems": [ + "7 lane bị cắt ở mép phải — lane Paused mất một nửa.", + "Kéo-thả có tác dụng thật: thả vào Running là chạy task ngay " + "(schedule_task_tab.py:265), không cảnh báo.", + "Kanban/Calendar là combo, không phải tab.", + ], + "changes": [ + "Giữ đủ 7 lane, thu hẹp cho vừa một màn. Không gộp lane nào.", + "Combo → cặp tab Kanban | Lịch.", + "Lane Running có viền cảnh báo.", + ], + # All 7 STATUSES are shown — merging any of them into an "other" menu + # would hide existing functionality, which this redesign must not do. + "wf": rail("Schedule Task") + ( + '
' + '
Kanban
Lịch
' + '
+ Task
✨ AI tạo
' + '
' + f'
BACKLOG (2)
' + f'{card("[AI] Xuất DS khách hàng B2B", "Chưa đặt lịch")}' + f'{card("Rà soát bảo mật trước release", "high")}' + '
' + f'
ĐÃ LÊN LỊCH (2)
' + f'{card("Quét lại chỉ mục ISO", "08-11 14:32")}' + f'{card("Báo cáo doanh thu 08:00", "08-09 14:32")}' + '
' + f'
ĐANG CHẠY (1) ⚠
' + f'{card("Đồng bộ heartbeat trạm sạc", "08-08 · high")}' + '
' + f'
CHỜ DUYỆT (1)
' + f'{card("Chờ kế toán duyệt số liệu T7", "Chưa đặt lịch")}' + '
' + f'
XONG (2)
' + f'{card("Sao lưu CSDL hằng đêm", "08-07 · critical")}' + f'{card("[AI] Slide tổng kết Q3", "Thành công")}' + '
' + f'
LỖI (1)
' + f'{card("Kiểm tra chứng chỉ TLS", "critical · Lỗi")}' + '
' + f'
TẠM DỪNG (1)
' + f'{card("Dọn log cũ hơn 90 ngày", "low")}' + '
' + '
' + '
Đủ 7 lane theo core.tasks.STATUSES — ' + 'không gộp, không giấu lane nào. Lane hẹp lại để vừa một màn, hết cuộn ngang.
'), + }, + "monitoring-tổng-quan": { + "problems": [ + "Sáu group box, hai cột — màn dày đặc nhất app.", + "Trộn 3 mối quan tâm: chi phí · tài nguyên · bảo mật.", + "Bảng giá model nhét chung hàng với thanh CPU/RAM.", + ], + "changes": [ + "Tách thành các mục có tiêu đề, cuộn dọc một cột chính.", + "Bảng giá model tách ra thành mục riêng.", + ], + "wf": rail() + ( + '
Monitoring
' + '
Tổng quan
Bảo mật
' + '
MCP
Hành động
Agent
' + '
Agents Admin
Công cụ
Icon
' + '
' + '
TOKEN & CHI PHÍ
' + '
395.4KTổng token
' + '
$0.31Chi phí
' + '
57Lượt gọi
' + '
—Ngân sách
' + '
TÀI NGUYÊN
' + '
CPU 34% · RAM 2.1/8 GB · Đĩa 41 GB trống
' + '
SANDBOX & QUYỀN
' + '
Tệp: chỉ trong workspace · Mạng: chặn · ' + 'Tiến trình: giới hạn 4
' + '
NHẬT KÝ GẦN ĐÂY
' + '
✕ Chặn đọc personal.xlsx (ngoài sandbox)
' + '✓ pytest tests/test_stations.py → 4 passed
' + '✕ jira.create_issue — 401 token hết hạn
' + '
'), + }, + "monitoring-công-cụ": { + "problems": [ + "Màn duy nhất còn hiện dải tab — 7 màn Monitoring kia bị ẩn. Không nhất quán.", + "Tab Tool/Connector lọt thỏm trong một mục nav.", + "Cấu hình kết nối tách khỏi Settings → thiết lập ở 2 nơi.", + ], + "changes": [ + "Bỏ ẩn dải tab cho toàn bộ Monitoring → 8 tab một hàng; Tool/Connector ngang hàng.", + ], + "wf": rail() + ( + '
Monitoring
' + '
Tổng quan
Bảo mật
' + '
MCP
Hành động
Agent
' + '
Agents Admin
Công cụ
' + '
Icon
' + '
Tool
Connector
' + '
Kiểm tra Internet
' + '
' + + li("read_file", "Đọc tệp trong sandbox — ☑ bật") + + li("write_file", "Ghi tệp trong sandbox — ☑ bật") + + li("run_command", "Chạy lệnh shell — ☑ bật") + + li("fetch_url", "Tải nội dung URL — ☑ bật · ✓ Internet OK") + + li("image_gen", "Sinh ảnh — ☐ tắt") + + '
'), + }, + "dialog-settings": { + "problems": [ + "Năm group cuộn dọc, không mục lục.", + "Sandbox khoá bằng mật khẩu hard-code (settings_dialog.py:115).", + "Provider/Ngôn ngữ/Giao diện ở top bar, tách khỏi Settings.", + ], + "changes": [ + "Thêm cột mục lục bên trái; gom Provider/Ngôn ngữ/Giao diện vào đây.", + ], + "wf": ('
Cài đặt
' + '
' + + li("Chung", "ngôn ngữ · giao diện · khay", on=True) + + li("AI Provider", "Ollama · qwen2.5-coder") + + li("Bảo mật sandbox", "🔒 cần mở khoá") + + li("Tham số", "đính kèm · GraphRAG · tài nguyên") + + li("Auto Model Routing", "đang Tắt") + + '
' + '
Ngôn ngữ hiển thị
' + '
Tiếng Việt (VN)
' + '
Giao diện
Theo hệ thống
' + '
Nhà cung cấp AI
Ollama (local models)
' + '
Thu nhỏ xuống khay khi đóng
☑ Bật
' + '
' + '
Huỷ
Lưu
' + '
'), + }, + "dialog-task-editor": { + "problems": [ + "Năm group dọc — form dài nhất app, không thấy đang ở bước nào.", + ], + "changes": ["Chia 3 bước có tab: Nội dung → Lịch chạy → Liên kết."], + "wf": ('
Sửa task
' + '
① Nội dung
' + '
② Lịch chạy
③ Liên kết
' + '
Tiêu đề
' + '
Gửi báo cáo doanh thu hằng ngày 08:00
' + '
Mô tả
' + '
Gom số liệu ngày hôm trước, dựng bảng và gửi email ' + 'cho nhóm kế toán.
' + '
Project
' + '
Báo cáo tài chính Q3
' + '
Chạy bằng
' + '
Agent · qwen2.5-coder
' + '
Ưu tiên
' + '
medium
' + '
' + '
Huỷ
' + '
Lưu
'), + }, +} + +# Screens with no bespoke analysis get this generic treatment. +GENERIC = { + "problems": ["Bố cục hiện tại giữ nguyên; chưa phát hiện vấn đề sắp xếp nghiêm trọng."], + "changes": ["Chỉ áp thanh menu phẳng mới; nội dung bên trong giữ nguyên."], + "wf": "", +} + +DEAD = [ + ("AccountsTab", "ui/accounts_tab.py:153", "Quản lý tài khoản/nhóm đầy đủ, nhưng không được gắn vào MonitoringTab."), + ("LoginDialog", "ui/login_dialog.py:57", "Màn đăng nhập hoàn chỉnh; app.py:860 bỏ qua, hard-code user “local”."), + ("FlowBuilderDialog", "ui/flow_dialog.py:34", "Bị Co4E thay thế; không nơi nào gọi."), + ("AgentManagerTab", "ui/agent_manager_tab.py:28", "Chỉ dùng bởi FlowBuilderDialog → cũng không tới được."), + ("SkillManagerTab", "ui/skill_manager_tab.py", "Chỉ dùng bởi FlowBuilderDialog → cũng không tới được."), + ("McpServerEditDialog", "ui/mcp_servers_dialog.py:15", "Bị ExtConnectorEditDialog thay thế."), +] + +FLOWS = [ + ("Khởi động → màn đầu", + "python -m cowork_local → MainWindow → Workspace ▸ Project
Không có bước đăng nhập (LoginDialog bị bỏ qua)", + "Giữ nguyên đích đến. Sidebar phẳng nên Project là mục đầu, không còn nằm dưới nhánh Workspace."), + ("Tạo project → chat", + "Nav ▸ Workspace (mở nhánh) → Project → “+” → điền form → Lưu → chọn project → nav ▸ Cowork (mục vừa mới xuất hiện) → gõ", + "Sidebar ▸ Project → “+” → Lưu → sidebar ▸ Cowork (luôn nhìn thấy) → gõ.
Bớt 1 bước mở nhánh, và menu không đổi hình giữa chừng."), + ("Co4E: tạo → chạy → xem run", + "Nav ▸ Workspace ▸ Co4E → “+” trên dải tab → kéo agent từ sidebar → chọn node → sửa ở panel phải → Lưu → Chạy → bấm tab Flow Status để xem", + "Sidebar ▸ Co4E → “+ Workflow” trong danh sách trái → kéo → sửa phải → Lưu → Chạy.
Trạng thái run là một mục trong danh sách trái, không phải tab riêng."), + ("Tạo & chạy scheduled task", + "Nav ▸ Schedule → “+ Task” → form 5 group → Lưu → kéo thẻ vào lane Running (chạy ngay, không hỏi)", + "Sidebar ▸ Schedule Task → “+ Task” → form 3 tab → Lưu → kéo vào Running (lane có viền cảnh báo)."), + ("Duyệt tệp → AI sửa", + "Nav ▸ Workspace ▸ Folder → chọn tệp → bấm ✨ mở panel AI → gõ lệnh → xem plan → xem diff → Apply", + "Sidebar ▸ Folder → chọn tệp → ✨ mở lớp phủ AI → gõ → plan → diff → Apply.
Luồng giữ nguyên; panel không còn chiếm chỗ cố định."), +] + +# Which source files back each screen. A screen made of several widgets lists +# them all, so no control falls between two files. +SCREEN_FILES = { + "dashboard": ["ui\\dashboard_tab.py", "ui\\widgets.py"], + "schedule-kanban": ["ui\\schedule_task_tab.py"], + "schedule-calendar": ["ui\\calendar_view.py"], + "workspace-project": ["ui\\workspace_tab.py"], + "workspace-cowork": ["ui\\cowork_tab.py", "ui\\chat_panel.py", "ui\\composer.py", + "ui\\sidebar.py", "ui\\chat_view.py", "ui\\routing_toggle.py"], + "workspace-co4e": ["ui\\co4e_tab.py", "ui\\co4e_config_panel.py", "ui\\co4e_canvas.py"], + "workspace-folder": ["ui\\folder_tab.py", "ui\\terminal_panel.py", + "ui\\libreoffice_view.py"], + "workspace-graphrag": ["ui\\structure_graph_view.py"], + "monitoring-tổng-quan": ["ui\\monitoring_tab.py"], + "monitoring-agents-admin": ["ui\\agents_admin_tab.py"], + "monitoring-công-cụ": ["ui\\tools_admin_tab.py", "ui\\connectors_panel.py"], + "monitoring-icon": ["ui\\icons_admin_tab.py"], + "dialog-settings": ["ui\\settings_dialog.py"], + "dialog-task-editor": ["ui\\task_editor_dialog.py"], + "dialog-skills": ["ui\\skills_dialog.py"], + "dialog-file-edit": ["ui\\file_edit_dialog.py"], + "dialog-co4e-agent": ["ui\\co4e_agent_dialog.py"], + "dialog-ext-connector": ["ui\\ext_connector_dialog.py"], + "dialog-permission": ["ui\\permission_dialog.py"], + "dialog-login": ["ui\\login_dialog.py"], + "overlay-help-panel": ["ui\\help_agent_widget.py"], + # The shell is not a screen, but its controls are live and must be counted. + "__shell__": ["app.py"], +} + +# The ONLY controls that change place. Everything else stays where it is — +# keyed by the variable the AST found, so a rename breaks the link loudly. +MOVES = { + "self._nav_toggle_btn": "Giữ — nút MENU gập sidebar (150↔54px)", + "self.provider_combo": "→ menu tài khoản ở đáy sidebar", + "self.language_combo": "→ menu tài khoản ở đáy sidebar", + "self.theme_btn": "→ menu tài khoản ở đáy sidebar", + "self.settings_btn": "→ menu tài khoản ở đáy sidebar", + "self.project_list": "→ giữ ở màn Quản lý project + thêm thanh chọn đầu trang", + "self.view_combo": "→ đổi thành cặp tab Kanban | Lịch", + "self.flow_bar": "→ bỏ; chọn workflow từ danh sách trái", + "self._msg_btn": "→ đổi thành cặp tab Đồ thị | Tin nhắn", + "self.search_edit": "→ lên sidebar cùng RECENTS", + "self.search_btn": "→ lên sidebar cùng RECENTS", + "self.refresh_btn": "→ lên sidebar cùng RECENTS", +} + +# One function traced end to end, because "does the new design match the old +# behaviour?" is only answerable at this level of detail. +NEWCHAT = [ + ("Số lối vào", "1 — nút “Cuộc trò chuyện mới” trên toolbar Cowork " + "(cowork_tab.py:34)", + "2 — nút sidebar + nút toolbar cũ giữ nguyên", + "Tôi vẽ thêm nút sidebar mà chưa nói gì về nút cũ. " + "Giữ cả hai (bỏ nút cũ là xoá chức năng), cùng gọi một hàm."), + ("Thấy được khi nào", "Chỉ khi đang ở tab Cowork — mà tab này " + "tự ẩn khi chưa chọn project", + "Luôn thấy trên sidebar", "Mới dễ tới hơn. Chưa chọn project thì nút mờ đi."), + ("Chat mới thuộc project nào", "Project đang mở, ngầm định — không hiển thị ở đâu", + "Bộ chọn project ngay trên nút, trong sidebar", + "Cùng hành vi — vẫn là project đang mở " + "(ctx.active_project_id), nhưng nay nhìn thấy và đổi được tại chỗ."), + ("Đổi project trước khi tạo", "Phải rời Cowork → về tab Project → chọn dòng trong danh sách " + "→ quay lại Cowork → bấm nút. 4 bước.", + "Bấm droplist ngay trên nút → chọn → bấm nút. 2 bước, không rời màn.", + "Ít bước hơn, không thêm chức năng — vẫn là chọn project rồi tạo chat."), + ("Bấm từ màn khác", "Không xảy ra được — nút chỉ có trên Cowork", + "Chuyển sang Cowork rồi tạo chat mới", + "Hành vi mới, cần thiết vì nút giờ ở mọi màn."), + ("Việc thực sự làm", "new_session() (chat_panel.py:1653): " + "xoá messages · sinh session_id mới · dọn view, composer, plan, tệp vào/ra · " + "turn đang chạy vẫn chạy nền", + "Giữ y nguyên", "Không đổi."), + ("Lưu chat cũ", "Tự lưu; History refresh qua history_changed", + "Giữ y nguyên — RECENTS refresh", "Không đổi."), +] + +# Old location → new location for EVERY screen, so "nothing was removed" is +# something the reader can check rather than take on trust. +MAPPING = [ + ("nhóm", "Màn hình làm việc", "", "", ""), + ("", "Workspace ▸ Project", "Menu ▸ Workspace (mở nhánh) ▸ Project", + "Sidebar ▸ Project — lên cấp 1, bớt 1 lần mở nhánh", "giữ nguyên"), + ("", "Workspace ▸ Cowork", "Menu ▸ Workspace ▸ Cowork — biến mất nếu chưa chọn project", + "Sidebar ▸ Cowork — luôn thấy, mờ khi chưa chọn", "giữ nguyên"), + ("", "Workspace ▸ Co4E", "Menu ▸ Workspace ▸ Co4E", "Sidebar ▸ Co4E", "giữ nguyên"), + ("", "Workspace ▸ Folder (“Thư mục”)", + "Menu ▸ Workspace ▸ Thư mục", "Sidebar ▸ Folder", "giữ nguyên"), + ("", "Workspace ▸ GraphRAG", "Menu ▸ Workspace ▸ GraphRAG — biến mất nếu chưa chọn project", + "Sidebar ▸ GraphRAG — luôn thấy", "giữ nguyên"), + ("", "Schedule Task — Kanban", "Menu ▸ Schedule Task", + "Sidebar ▸ Schedule Task ▸ tab Kanban", "giữ nguyên"), + ("", "Schedule Task — Lịch", "Menu ▸ Schedule Task ▸ combo đổi sang “Lịch”", + "Sidebar ▸ Schedule Task ▸ tab Lịch — combo thành tab, dễ thấy hơn", "giữ nguyên"), + + ("nhóm", "Giám sát & vận hành — phần bạn hỏi", "", "", ""), + ("", "Dashboard", "Menu ▸ Dashboard (cấp 1)", + "Sidebar ▸ vùng đáy — vẫn 1 cú nhấp", "giữ nguyên"), + ("", "Monitoring ▸ Tổng quan", "Menu ▸ Monitoring (mở nhánh) ▸ Tổng quan", + "Sidebar ▸ Monitoring ▸ tab Tổng quan", "giữ nguyên"), + ("", "Monitoring ▸ Sự kiện bảo mật", "Menu ▸ Monitoring ▸ Sự kiện bảo mật", + "Sidebar ▸ Monitoring ▸ tab Sự kiện bảo mật", "giữ nguyên"), + ("", "Monitoring ▸ Lịch sử gọi MCP", "Menu ▸ Monitoring ▸ Lịch sử gọi MCP", + "Sidebar ▸ Monitoring ▸ tab Lịch sử gọi MCP", "giữ nguyên"), + ("", "Monitoring ▸ Nhật ký hành động", "Menu ▸ Monitoring ▸ Nhật ký hành động", + "Sidebar ▸ Monitoring ▸ tab Nhật ký hành động", "giữ nguyên"), + ("", "Monitoring ▸ Trạng thái Agent", "Menu ▸ Monitoring ▸ Trạng thái Agent", + "Sidebar ▸ Monitoring ▸ tab Trạng thái Agent", "giữ nguyên"), + ("", "Monitoring ▸ Agents Admin", "Menu ▸ Monitoring ▸ Agents Admin", + "Sidebar ▸ Monitoring ▸ tab Agents Admin", "giữ nguyên"), + ("", "Monitoring ▸ Công cụ", "Menu ▸ Monitoring ▸ Công cụ ▸ tab con Tool | Connector", + "Sidebar ▸ Monitoring ▸ tab Công cụ ▸ Tool | Connector", "giữ nguyên"), + ("", "Monitoring ▸ Icon", "Menu ▸ Monitoring ▸ Icon", + "Sidebar ▸ Monitoring ▸ tab Icon", "giữ nguyên"), + + ("nhóm", "Thành phần bị dời chỗ", "", "", ""), + ("", "History (lịch sử chat)", + "Pane giữa, chỉ ở tab Cowork. Đã gom theo project.", + "Sidebar ▸ RECENTS — vẫn gom theo project + “Tất cả project…”", + "giữ, dễ tới hơn"), + ("", "Bộ chọn project", + "Pane trái, chỉ ở tab Project", + "Thanh chọn đầu trang, dùng chung mọi màn", "giữ, dễ tới hơn"), + ("", "Provider · Ngôn ngữ · Giao diện", "Thanh trên cùng (topbar)", + "Menu tài khoản ở đáy sidebar — gom cùng chỗ với Cài đặt", "giữ nguyên"), + ("", "Nút Cài đặt", "Thanh trên cùng", "Menu tài khoản ở đáy sidebar", "giữ nguyên"), + + ("nhóm", "Hộp thoại & lớp phủ", "", "", ""), + ("", "11 hộp thoại", "Mở từ nút trên các màn tương ứng", + "Không đổi — vẫn mở từ đúng những nút đó", "giữ nguyên"), + ("", "Robot trợ giúp · Terminal · Composer", "Lớp phủ / panel thu gọn", + "Không đổi", "giữ nguyên"), +] + +# Every set of tabs / lanes / modes in the app, so nothing is hidden by a +# truncated wireframe. "Nhìn thấy" = does the strip appear on screen at all. +TAB_GROUPS = [ + ("Thanh menu trái", "4 mục", "Dashboard · Schedule Task · Workspace · Monitoring", + "có", "app.py:154"), + ("Workspace ▸ mục con", "5 mục", + "Project · Cowork · Co4E · Folder · GraphRAG", + "không — hide_tab_bar(), và Cowork/GraphRAG " + "còn tự ẩn khi chưa chọn project", "workspace_tab.py:43"), + ("Monitoring ▸ mục con", "8 mục", + "Tổng quan · Sự kiện bảo mật · Lịch sử gọi MCP · Nhật ký hành động · " + "Trạng thái Agent · Agents Admin · Công cụ · Icon", + "không — hide_tab_bar()", "monitoring_tab.py:215"), + ("Công cụ ▸ tab con", "2 tab", "Tool · Connector", + "có — màn duy nhất còn hiện dải tab", "tools_admin_tab.py:91"), + ("Schedule ▸ chế độ xem", "2 chế độ", "Kanban · Lịch", + "là combo, không phải tab", "schedule_task_tab.py:171"), + ("Kanban ▸ lane trạng thái", "7 lane", + "backlog · scheduled · running · waiting_input · done · failed · paused", + "cắt ở mép phải, phải cuộn ngang", "tasks.py:24"), + ("Co4E ▸ sidebar", "3 tab icon", "Workflows · Agents · Skills", + "chỉ có icon, tên nằm trong tooltip", "co4e_tab.py:426"), + ("Co4E ▸ dải tab flow", "1 + N", "Flow Status (ghim) + mỗi workflow đang mở một tab", + "có, kiểu trình duyệt", "co4e_tab.py:556"), + ("Folder ▸ trình xem", "5 trang", "trống · mã nguồn · HTML · tài liệu · ảnh (+ bảng tính)", + "tự đổi theo đuôi tệp, không có tab", "folder_tab.py:322"), + ("GraphRAG ▸ khung trái", "2 trang", "Đồ thị · Tin nhắn", + "là nút bấm, không phải tab", "structure_graph_view.py:217"), + ("Dialog Tạo task bằng AI", "2 tab", "Sinh bằng AI · Nhập từ Excel", + "có", "schedule_task_tab.py:530"), + ("Dialog Kết nối ngoài", "2 chế độ", "MCP (stdio) · REST API", + "là combo đổi trang", "ext_connector_dialog.py:23"), + ("Dialog Đăng nhập (màn chết)", "3 trang", + "Khởi tạo lần đầu · Đăng nhập · Dự phòng offline", + "không tới được", "login_dialog.py:74"), + ("Dialog Flow Builder (màn chết)", "3 tab", + "Flow · Agents · Skills", "không tới được", + "flow_dialog.py:247"), +] + +# Every collapse / expand affordance the app ships. The redesign must keep all +# of them — folding a panel away is a feature users rely on, and dropping one +# would be removing functionality, not simplifying. +COLLAPSIBLES = [ + ("Thanh menu chính", "MENU ‹ ở đầu thanh — gập còn dải icon (150px → 54px)", + "app.py:409", "giữ"), + ("Pane Project", "chevron ‹ trên đầu danh sách project", + "workspace_tab.py:368", "giữ"), + ("Pane Lịch sử", "chevron trên đầu History, gập thành dải mỏng", + "sidebar.py:167 · workspace_tab.py:247", "giữ"), + ("Pane Tệp trong chat", "chevron › — gập panel Files bên phải khung chat", + "chat_panel.py:709", "giữ"), + ("Panel Hỏi đáp GraphRAG", "chevron › — gập panel agent bên phải đồ thị", + "structure_graph_view.py:615", "giữ"), + ("Panel cấu hình bước Co4E", "nút gập panel phải của canvas", + "co4e_tab.py:767", "giữ"), + ("Panel Tin nhắn Co4E", "gập khung log dưới canvas — mặc định đang gập", + "co4e_tab.py:894", "giữ"), + ("Terminal trong Thư mục", "bấm thanh tiêu đề để mở/gập — mặc định đang gập", + "terminal_panel.py:156", "giữ"), + ("Panel AI sửa tệp", "nút ✨ bật/tắt panel — mặc định đang ẩn", + "folder_tab.py:777", "giữ"), + ("Đồ thị ⇄ Tin nhắn (GraphRAG)", "nút đổi nội dung pane trái", + "structure_graph_view.py:416", "giữ — đổi thành cặp tab"), + ("Khối kết quả công cụ trong chat", "bấm tiêu đề để mở/gập output dài", + "chat_view.py:253", "giữ"), +] + +NAV_BEFORE = """Dashboard +Schedule Task +Workspace ▼ ← nhánh accordion, tab strip bên trong BỊ ẨN + Project + Cowork ← TỰ ẨN khi chưa chọn project + Co4E + Folder + GraphRAG ← TỰ ẨN khi chưa chọn project +Monitoring ▼ ← nhánh accordion, tab strip BỊ ẨN + Tổng quan + Sự kiện bảo mật + Lịch sử gọi MCP + Nhật ký hành động + Trạng thái Agent + Agents Admin + Công cụ + Icon""" + +NAV_AFTER = """[ + Đoạn chat mới ] ← hành động chính, trên cùng +────────────── +Project ← màn đầu, giữ nguyên +Cowork ← luôn hiện (mờ đi nếu chưa chọn project) +Co4E +Folder +GraphRAG ← luôn hiện (mờ đi nếu chưa chọn project) +Schedule Task +────────────── +RECENTS ← History dời từ pane giữa lên đây + · thread gần nhất… +────────────── (ghim đáy — nhóm phụ trợ) +Dashboard ← vẫn 1 cú nhấp như cũ +Monitoring ← BỎ ẨN dải tab, đủ 8 mục nằm ngang trong trang: + [Tổng quan] [Sự kiện bảo mật] [Lịch sử gọi MCP] + [Nhật ký hành động] [Trạng thái Agent] + [Agents Admin] [Công cụ] [Icon] +👤 local · Provider ▾ ← gom Provider/Language/Theme/Settings""" + +NAV_PROBLEMS = [ + ("Accordion 2 cấp, không phẳng", + "app.py:170 ghi là \“Claude-style\” nhưng là QTreeWidget " + "accordion. Claude dùng danh sách phẳng."), + ("Mục tự biến mất", + "workspace_tab.py:485 ẩn Cowork và GraphRAG khi chưa chọn project."), + ("Tab strip bị ẩn", + "hide_tab_bar() ẩn dải tab có sẵn → menu là đường duy nhất."), + ("History chỉ có ở tab Cowork", + "workspace_tab.py:169. Điểm làm tốt phải giữ: lịch sử " + "đã gom theo project — lưu trong <project>/.cowork_history " + "(config.py:590), hiển thị gom nhóm ở sidebar.py:193."), + ("Monitoring gom 5 việc rời rạc", + "Chi phí · log bảo mật · quản trị agent · cấu hình tool · thư viện icon."), + ("Dashboard trùng Monitoring ▸ Tổng quan", "Cùng bộ StatCard + BudgetCard."), + ("Top bar giữ thiết lập", "Provider/Ngôn ngữ/Giao diện ở topbar, tách khỏi Cài đặt."), + ("Header không đổi theo màn", "Mọi sub-tab đều hiện \“Workspace — Projects\”."), + ("LỖI: bấm mục con Workspace, menu nhảy về mục cha", + "_goto gọi refresh() (app.py:726) → " + "subtabs_changed vô điều kiện (workspace_tab.py:497) → " + "takeChildren() (app.py:691) huỷ mục vừa bấm.
" + "Đo được: 5/5 mục Workspace mất highlight, 0/8 mục Monitoring bị."), +] + +CSS = """ +:root{--bg:#FFFFFF;--sf:#F8F8F8;--rz:#FFFFFF;--bd:#E5E5E5;--bds:#CECECE;--tx:#3B3B3B;--mut:#616161;--fnt:#6E6E6E;--ac:#005FB8;--nav:#F8F8F8;--navb:#E5E5E5;--navs:#E4E6F1;--ok:#317A2D;--warn:#8F6500;--bad:#CD3131;--r:4px} +@media(prefers-color-scheme:dark){:root{--bg:#1F1F1F;--sf:#252526;--rz:#313131;--bd:#2B2B2B;--bds:#3C3C3C;--tx:#CCCCCC;--mut:#9D9D9D;--fnt:#9A9A9A;--ac:#4DAAFC;--nav:#181818;--navb:#2B2B2B;--navs:#04395E;--ok:#89D185;--warn:#CCA700;--bad:#F76464;--r:4px}} +:root[data-theme=dark]{--bg:#1F1F1F;--sf:#252526;--rz:#313131;--bd:#2B2B2B;--bds:#3C3C3C;--tx:#CCCCCC;--mut:#9D9D9D;--fnt:#9A9A9A;--ac:#4DAAFC;--nav:#181818;--navb:#2B2B2B;--navs:#04395E;--ok:#89D185;--warn:#CCA700;--bad:#F76464;--r:4px} +:root[data-theme=light]{--bg:#FFFFFF;--sf:#F8F8F8;--rz:#FFFFFF;--bd:#E5E5E5;--bds:#CECECE;--tx:#3B3B3B;--mut:#616161;--fnt:#6E6E6E;--ac:#005FB8;--nav:#F8F8F8;--navb:#E5E5E5;--navs:#E4E6F1;--ok:#317A2D;--warn:#8F6500;--bad:#CD3131;--r:4px} +*{box-sizing:border-box} +body{margin:0;background:var(--bg);color:var(--tx); +font:14px/1.55 "Segoe UI Variable Text","Segoe UI",system-ui,sans-serif} +.wrap{max-width:1180px;margin:0 auto;padding:0 24px 64px} +header{padding:34px 0 16px;border-bottom:1px solid var(--bd);margin-bottom:20px} +h1{font-size:25px;margin:0 0 8px;letter-spacing:-.02em} +h2{font-size:19px;margin:38px 0 6px;padding-top:16px;border-top:1px solid var(--bd)} +h3{font-size:15px;margin:20px 0 6px} +p{margin:8px 0}.mut{color:var(--mut)}.fnt{color:var(--fnt)} +code{font:13px "Cascadia Code",Consolas,monospace;background:var(--sf); +border:1px solid var(--bd);border-radius:4px;padding:1px 5px} +.note{background:var(--sf);border:1px solid var(--bd);border-left:3px solid var(--ac); +border-radius:var(--r);padding:9px 13px;margin:10px 0;font-size:13px} +.note.warn{border-left-color:var(--warn)}.note.bad{border-left-color:var(--bad)} +.toc{background:var(--sf);border:1px solid var(--bd);border-radius:var(--r);padding:18px 22px} +.toc ol{margin:6px 0;padding-left:22px;columns:2;column-gap:36px} +.toc a{color:var(--tx);text-decoration:none}.toc a:hover{color:var(--ac);text-decoration:underline} +.sec{border:1px solid var(--bd);border-radius:var(--r);margin:16px 0;overflow:hidden;background:var(--sf)} +.sec>.hd{padding:10px 16px;border-bottom:1px solid var(--bd);display:flex; +align-items:baseline;gap:10px;flex-wrap:wrap} +.sec>.hd b{font-size:15px}.sec>.bd{padding:14px 16px} +.tag{font-size:12px;padding:2px 8px;border-radius:4px;background:var(--bg); +border:1px solid var(--bd);color:var(--mut)} +.cap{font-size:11px;font-weight:600;letter-spacing:.06em;text-transform:uppercase; +color:var(--fnt);margin:12px 0 5px} +.shot{border:1px solid var(--bd);border-radius:var(--r);overflow:hidden;background:var(--bg)} +.shot img{display:block;width:100%;height:auto} +.miss{padding:26px;text-align:center;color:var(--bad);background:var(--bg); +border:1px dashed var(--bad);border-radius:var(--r);font-size:14px} +ul.pr{margin:4px 0;padding-left:18px;font-size:13px}ul.pr li{margin:3px 0} +.lead{font-size:14px;color:var(--tx);margin:0 0 6px;max-width:92ch} +p.rg{font-size:12.5px;color:var(--mut);margin:6px 0 0;line-height:1.55} +p.rg b{color:var(--tx)} +.cols{display:grid;grid-template-columns:1fr 1fr;gap:20px} +.cols.pc{gap:16px;margin-top:10px}.cols.pc .cap{margin:0 0 4px} +details.ctl{margin-top:12px;border:1px solid var(--bd);border-radius:var(--r); +background:var(--bg)} +details.ctl summary{padding:7px 12px;cursor:pointer;font-size:12.5px;color:var(--tx)} +details.ctl[open] summary{border-bottom:1px solid var(--bd)} +details.ctl table{margin:0;font-size:12px} +details.ctl td,details.ctl th{padding:4px 10px} +td.mv{color:var(--ac)} +@media(max-width:900px){.cols{grid-template-columns:1fr}.toc ol{columns:1}} +pre.tree{background:var(--bg);border:1px solid var(--bd);border-radius:var(--r); +padding:16px;font:12.5px/1.65 "Cascadia Code",Consolas,monospace;overflow-x:auto;margin:0} +table{width:100%;border-collapse:collapse;margin:10px 0;font-size:13px} +th,td{text-align:left;padding:6px 10px;border-bottom:1px solid var(--bd);vertical-align:top} +th{color:var(--mut);font-size:12px;text-transform:uppercase;letter-spacing:.05em} +/* ---- wireframe vocabulary ---- */ +.wf{display:flex;height:570px;border:1px solid var(--bds);border-radius:var(--r); +overflow:hidden;background:var(--bg);font-size:11px} +/* The rail must never crop: its bottom group is real navigation. */ +.wf .rail{overflow:visible} +.wf .rail{width:150px;flex:none;background:var(--nav);border-right:1px solid var(--navb); +padding:8px;display:flex;flex-direction:column;gap:3px} +.wf .newbtn{background:var(--ac);color:#fff;border-radius:4px;padding:6px;text-align:center; +font-weight:600} +/* flex:none — inside the column rail this otherwise collapses to a sliver. */ +.wf .rpick{flex:none;background:var(--rz);border:1px solid var(--bds);border-radius:4px; +padding:5px 7px;margin-bottom:5px;font-weight:600;display:flex;align-items:center; +justify-content:space-between;gap:4px;line-height:1.3} +.wf .rpick>span:first-child{overflow:hidden;text-overflow:ellipsis;white-space:nowrap} +.wf .rpick .cv{color:var(--mut);font-weight:400;flex:none} +.wf .newbtn{flex:none} +.wf .i{padding:5px 7px;border-radius:4px;color:var(--tx)} +.wf .i.on{background:var(--navs);border-left:2px solid var(--ac);font-weight:600} +.wf .i.sm{color:var(--mut);font-size:10px;padding:3px 7px} +.wf .hd{font-size:9px;letter-spacing:.1em;color:var(--fnt);padding:4px 7px;font-weight:700} +.wf .scope{font-size:10px;font-weight:600;color:var(--tx);padding:2px 7px 4px} +.wf .i.allp{color:var(--ac);font-style:italic} +.wf .sep{height:1px;background:var(--navb);margin:5px 0} +.wf .acct{margin-top:6px;padding:6px 7px;border-top:1px solid var(--navb);color:var(--mut);font-size:10px} +.wf .main,.wf .c{display:flex;flex-direction:column;gap:6px;padding:10px;flex:1;min-width:0} +.wf .main.dlg{border:none} +.wf .ttl{font-weight:700;font-size:13px;white-space:nowrap} +/* Rows STRETCH by default so nested columns fill the height — `.tb` opts a + toolbar row back into vertical centring. Getting this backwards is what + collapsed every wireframe into a thin strip floating mid-panel. */ +.wf .r{display:flex;gap:6px;align-items:stretch;min-height:0} +.wf .r.tb{align-items:center;flex:none} +.wf .r.end{justify-content:flex-end} +.wf .grow{flex:1;min-height:0} +.wf .b{background:var(--sf);border:1px solid var(--bd);border-radius:4px;padding:6px 8px;min-height:22px} +.wf .b.tall{min-height:70px} +/* nowrap: a wrapped node graph reads as a broken diagram, not a flow */ +.wf .b.canvas{display:flex;align-items:center;justify-content:center;gap:5px; +background:var(--rz);flex-wrap:nowrap;padding:12px;overflow:hidden} +.wf .b.code{background:var(--rz);font-family:"Cascadia Code",Consolas,monospace; +font-size:10px;line-height:1.7;overflow:hidden} +.wf .b.chart{background:var(--rz);padding:8px;color:var(--ac);min-height:70px} +.wf .b.chart svg{width:100%;height:100%;display:block} +.wf .b.hero,.wf .b.kpi{display:flex;flex-direction:column;justify-content:center; +font-weight:700} +.wf .b.hero{flex:none;width:130px;font-size:20px;align-items:center;text-align:center} +.wf .b.kpi{flex:1;font-size:14px} +.wf .s{display:block;font-size:9.5px;font-weight:400;color:var(--fnt);margin-top:2px} +.wf .lbl{color:var(--mut);font-size:10px} +.wf .hd2{font-size:9px;letter-spacing:.09em;color:var(--fnt);font-weight:700; +margin-top:2px} +.wf .hd2.row{display:flex;align-items:center;justify-content:space-between} +.wf .pchev{color:var(--mut);font-size:12px;font-weight:400} +/* The rail's own MENU collapse control (150px <-> 54px in the app). */ +.wf .menutog{display:flex;align-items:center;justify-content:space-between; +color:var(--fnt);font-size:9px;letter-spacing:.1em;font-weight:700;padding:2px 6px 6px} +.wf .menutog .chev{font-size:13px;font-weight:400} +.wf .rail.narrow{width:46px;align-items:center} +.wf .rail.narrow .menutog{justify-content:center;padding:2px 0 6px} +.wf .newbtn.ic,.wf .i.ic,.wf .acct.ic{text-align:center;padding:5px 0;width:100%} +.wf .acct.ic{border-top:1px solid var(--navb)} +.wf .w22{flex:none;width:22%} +.wf .inp{background:var(--rz);border:1px solid var(--bds);border-radius:4px; +padding:6px 8px;color:var(--tx);min-height:26px;overflow:hidden} +.wf .inp.tall{min-height:52px} +.wf .btn{background:var(--rz);border:1px solid var(--bds);border-radius:4px; +padding:4px 9px;white-space:nowrap;align-self:center} +.wf .btn.pri{background:var(--ac);color:#fff;border-color:transparent;font-weight:600} +.wf .tab{padding:4px 10px;border-bottom:2px solid transparent;color:var(--mut);white-space:nowrap} +.wf .tab.on{border-bottom-color:var(--ac);color:var(--tx);font-weight:600} +.wf .stat{border-top:1px solid var(--bd);padding-top:5px;color:var(--fnt); +font-size:10px;flex:none} +/* Shared project picker — same spot on every project-scoped screen. */ +.wf .r.pb{border-bottom:1px solid var(--bd);padding-bottom:7px;margin-bottom:2px} +.wf .pick{background:var(--navs);border:1px solid var(--navb);border-radius:4px; +padding:4px 10px;font-weight:600;color:var(--tx)} +.wf .pane{background:var(--sf);border:1px solid var(--bd);border-radius:4px;gap:3px} +.wf .lane{background:var(--sf);border:1px solid var(--bd);border-radius:4px;padding:6px;gap:5px} +.wf .lane.warn{border-color:var(--warn)} +/* 7 lanes must fit without horizontal scrolling — that is the whole point. */ +.wf .k7{gap:4px} +.wf .k7 .lane{padding:5px;gap:4px;min-width:0} +.wf .k7 .card{font-size:10px;padding:4px 6px} +.wf .k7 .hd2{font-size:8.5px} +.tag.ok{color:var(--ok);border-color:var(--ok)} +b.ok,span.ok{color:var(--ok)}b.bad,span.bad{color:var(--bad)} +td.ok{color:var(--ok);white-space:nowrap} +tr.grp td{background:var(--sf);font-weight:700;font-size:13px; +border-bottom:1px solid var(--bds);padding-top:14px} +.wf .li{padding:4px 7px;border-radius:4px;color:var(--tx);line-height:1.35} +.wf .li.on{background:var(--navs);font-weight:600} +.wf .card{background:var(--rz);border:1px solid var(--bd);border-radius:4px; +padding:5px 7px;line-height:1.35} +.wf .chat{gap:6px;padding:0} +.wf .msg{border-radius:5px;padding:6px 9px;line-height:1.45;max-width:88%} +.wf .msg.u{background:var(--navs);align-self:flex-end} +.wf .msg.a{background:var(--sf);border:1px solid var(--bd)} +.wf .node{background:var(--sf);border:1px solid var(--bds);border-radius:4px; +padding:6px 8px;white-space:nowrap;font-size:10px;flex:none} +.wf .node.ok{border-color:var(--ok);color:var(--ok)} +.wf .node.run{border-color:var(--ac);color:var(--ac);font-weight:600} +.wf .arw{color:var(--fnt)} +.wf .diff{font-family:"Cascadia Code",Consolas,monospace;font-size:9.5px; +line-height:1.7;background:var(--rz);border-radius:4px;padding:5px 7px} +.wf .add{color:var(--ok)}.wf .del{color:var(--bad)} +.wf .ok{color:var(--ok)}.wf .bad{color:var(--bad)} +.wf .cm{color:var(--fnt)}.wf .kw{color:var(--ac)}.wf .fn{color:var(--warn)} +.wf .w26{flex:none;width:26%}.wf .w28{flex:none;width:28%}.wf .w32{flex:none;width:32%} +@media(max-width:760px){.wf{height:auto;flex-direction:column}.wf .rail{width:auto}} +.tgl{position:fixed;top:16px;right:16px;z-index:9;background:var(--sf);color:var(--tx); +border:1px solid var(--bds);border-radius:var(--r);padding:7px 13px;cursor:pointer;font-size:13px} +.sw{display:inline-flex;border:1px solid var(--bds);border-radius:4px;overflow:hidden;margin-left:auto} +.sw button{background:var(--bg);color:var(--mut);border:0;padding:3px 11px;cursor:pointer;font-size:12px} +.sw button.on{background:var(--ac);color:#fff;font-weight:600} +""" + +JS = """ +const root=document.documentElement; +function setTheme(t){root.setAttribute('data-theme',t); + document.getElementById('tgl').textContent=t==='dark'?'☀ Sáng':'🌙 Tối'; + document.querySelectorAll('.sec').forEach(s=>swap(s,t));} +function swap(sec,t){const i=sec.querySelector('img.shotimg');if(!i)return; + const src=t==='dark'?i.dataset.dark:i.dataset.light;if(src)i.src=src; + sec.querySelectorAll('.sw button').forEach(b=>b.classList.toggle('on',b.dataset.t===t));} +document.getElementById('tgl').onclick=()=>setTheme( + root.getAttribute('data-theme')==='dark'?'light':'dark'); +document.querySelectorAll('.sw button').forEach(b=>b.onclick=()=>{ + swap(b.closest('.sec'),b.dataset.t);}); +setTheme(matchMedia('(prefers-color-scheme: dark)').matches?'dark':'light'); +""" + + +def esc(s: str) -> str: + return s.replace("&", "&").replace("<", "<").replace(">", ">") + + +def load_controls() -> dict: + """file path -> {controls, menu_actions}, from tools/extract_controls.py.""" + src = DOCS / "screens" / "controls.json" + if not src.exists(): + return {} + return {f["file"]: f for f in json.loads(src.read_text(encoding="utf-8"))} + + +def controls_table(slug: str, index: dict) -> str: + """A collapsed, exhaustive control list for one screen.""" + files = SCREEN_FILES.get(slug) + if not files: + return "" + rows, n = [], 0 + for fname in files: + rec = index.get(fname) + if not rec: + continue + short = fname.split("\\\\")[-1] + for c in rec["controls"]: + label = c.get("label_vi") or c.get("label") or "—" + label = label.replace("tr(", "").strip("'\")")[:60] + act = "; ".join(h.split(" → ")[-1] for h in c["signals"])[:70] or "—" + dest = MOVES.get(c["var"], "giữ nguyên tại chỗ") + cls = "ok" if dest.startswith("giữ") else "mv" + rows.append(f'' + f'' + f'' + f'') + n += 1 + for a in rec["menu_actions"]: + label = a.get("label_vi") or a.get("label") or "—" + label = label.replace("tr(", "").strip("'\")")[:60] + rows.append(f'' + f'' + f'' + f'') + n += 1 + if not rows: + return "" + return (f'
Kiểm kê control — {n} mục ' + f'(trích bằng AST, không đọc tay)' + f'
{esc(label)}{c["kind"]}{esc(act)}{short}:{c["line"]}{dest}
{esc(label)}menu chuột phải—{short}:{a["line"]}giữ nguyên tại chỗ
' + f'{"".join(rows)}
NhãnLoạiHàm xử lýNguồnSau khi sửa
') + + +def embed(rel: str) -> str: + """PNG at `rel` as a data: URI (standalone builds only).""" + raw = (DOCS / rel.split("?")[0]).read_bytes() + return "data:image/png;base64," + base64.b64encode(raw).decode("ascii") + + +def bust(rel: str) -> str: + """Append a content stamp to an image URL. + + Re-capturing overwrites the PNGs but keeps their names, so a browser (or the + editor's HTML preview) happily serves the previous render from cache and the + page looks stale even though it was just rebuilt. Stamping the URL with the + file's mtime forces a fetch whenever the picture actually changed. + """ + path = DOCS / rel + try: + return f"{rel}?v={int(path.stat().st_mtime)}" + except OSError: + return rel + + +def main() -> int: + stamp = datetime.now().strftime("%H:%M %d/%m/%Y") + cidx = load_controls() + recs = json.loads(MANIFEST.read_text(encoding="utf-8")) + by_slug: dict[str, dict] = {} + for r in recs: + by_slug.setdefault(r["slug"], {"title": r["title"], "note": r["note"], "shots": {}}) + by_slug[r["slug"]]["shots"][r["theme"]] = r + order = list(by_slug) + + toc = "".join( + f'
  • {esc(by_slug[s]["title"])}
  • ' for s in order) + + secs = [] + for n, slug in enumerate(order, 1): + info = by_slug[slug] + a = ANALYSIS.get(slug, GENERIC) + dark, light = info["shots"].get("dark"), info["shots"].get("light") + + if dark and dark["file"]: + src = embed if STANDALONE else bust + d_url, l_url = src(dark["file"]), src((light or dark)["file"]) + shot = (f'
    ') + sw = ('' + '') + else: + err = esc((dark or {}).get("error", "không rõ")) + shot = f'
    Không chụp được màn này
    {err}
    ' + sw = "" + + wf = (f'
    Đề xuất — bố cục mới
    {a["wf"]}
    ' + if a["wf"] else + '
    Đề xuất — bố cục mới
    ' + '

    Giữ nguyên bố cục bên trong; chỉ đổi thanh menu sang danh sách phẳng.

    ') + + # What the screen is, plus a legend for the regions visible in the shot. + de = DESCRIPTIONS.get(slug) + intro = f'

    {de["d"]}

    ' if de else "" + legend = "" + if de and de.get("r"): + bits = " · ".join( + f'{lab}{": " + txt if txt else ""}' for lab, txt in de["r"]) + legend = f'

    {bits}

    ' + + secs.append(f"""
    +
    {n}. {esc(info['title'])}{esc(info['note'])}{sw}
    +
    +{intro} +
    Hiện tại
    {shot} +{legend} +{wf} +{controls_table(slug, cidx)} +
    +
    Vấn đề
      {''.join(f'
    • {p}
    • ' for p in a['problems'])}
    +
    Thay đổi
      {''.join(f'
    • {c}
    • ' for c in a['changes'])}
    +
    """) + + flows = "".join( + f'{t}{b}{af}' + for t, b, af in FLOWS) + navp = "".join(f'{t}{d}' for t, d in NAV_PROBLEMS) + tabs = "".join( + f'{g}{n}{items}' + f'{seen}{src}' + for g, n, items, seen, src in TAB_GROUPS) + newchat = "".join( + f'{a}{o}{n}{v}' + for a, o, n, v in NEWCHAT) + shell_ctl = controls_table("__shell__", cidx) + colls = "".join( + f'{n}{how}{src}' + f'{keep}' for n, how, src, keep in COLLAPSIBLES) + rail_open = rail("Cowork") + ('
    Nội dung
    ' + '
    ') + rail_shut = rail_collapsed() + ('
    Nội dung
    ' + '
    ') + mapping = "".join( + (f'{name}' if kind == "nhóm" + else f'{name}{old}{new}' + f'{keep}') + for kind, name, old, new, keep in MAPPING) + dead = "".join(f'{n}{f}{d}' + for n, f, d in DEAD) + + html = f""" + +CoworkLocal — Audit UI/UX + +
    +
    +

    CoworkLocal — Audit UI/UX

    +

    Hiện trạng {len(order)} màn hình · đề xuất sắp xếp lại theo UI/UX kiểu Claude · +bảng màu Visual Studio Code · dựng lúc {stamp}

    +
    Ràng buộc: chỉ sắp xếp lại, không xoá/thêm chức năng. +Hai chỗ lệch được đánh dấu ở Phần 1.
    +
    Ảnh chụp: render offscreen trên bản sao dữ liệu +(scheduler tắt, hash dữ liệu thật trước/sau giống hệt). Nạp dữ liệu mẫu: 3 project · +6 chat · 10 task · 3 workflow · 45 ngày token. Ảnh đã chỉnh menu sáng đúng mục — +xem lỗi #9.
    +
    + +
    Mục lục +

    Phần 1 — Điều hướng · Phần 2 — Luồng người dùng · +Phần 3 — {len(order)} màn hình · Phần 4 — Màn chết

    +
      {toc}
    + +

    Phần 1 — Điều hướng

    +
    +
    Hiện tại
    {esc(NAV_BEFORE)}
    +
    Đề xuất
    {esc(NAV_AFTER)}
    +
    +

    Chín điểm đã xác minh trong code

    +{navp}
    Vấn đềChi tiết
    + +

    Khung ứng dụng — thanh trên cùng & thanh menu

    +

    Không thuộc màn nào nên liệt kê riêng.

    +{shell_ctl} + +

    Các nút thu gọn / mở rộng — giữ nguyên toàn bộ

    +

    App có 11 chỗ gập được. Thiết kế mới giữ đủ cả 11.

    +
    +
    Menu mở rộng
    {rail_open}
    +
    Menu đã gập (54px, chỉ còn icon)
    +
    {rail_shut}
    +
    + +{colls}
    Chỗ gập đượcCách dùngNguồnThiết kế mới
    + +

    Từng màn hình đi đâu sau khi sửa

    +

    Không màn nào bị bỏ. Giám sát (Dashboard + 8 màn Monitoring) ở nhóm +phụ trợ đáy sidebar, mở ra thấy đủ 8 tab.

    + +{mapping}
    Màn hìnhHiện tại ở đâuSau khi sửa ở đâuChức năng
    +
    Dashboard/Monitoring xuống đáy nhưng vẫn là mục cấp 1, vẫn +một cú nhấp. Sidebar chia theo tần suất: việc hằng ngày ở trên, quản trị ở dưới.
    + +

    Toàn bộ nhóm tab / lane / chế độ trong app

    +

    Đầy đủ, kể cả nhóm không hiện ra màn hình.

    + +{tabs}
    NhómSLCác mụcNhìn thấy?Nguồn
    +
    14 nhóm tab/lane, chỉ 4 nhóm hiện ra màn hình. +Số còn lại bị ẩn, thành combo, thành nút, hoặc không tới được.
    +
    Hai chỗ lệch — cần bạn quyết: +(1) Cowork/GraphRAG: biến mất → hiện nhưng mờ. +(2) Bỏ ẩn tab strip Monitoring (khôi phục thứ đã có).
    + +

    Phần 2 — Luồng người dùng

    +{flows}
    LuồngHiện tạiSau khi sửa
    + +

    Truy vết chi tiết: “Đoạn chat mới”

    + +{newchat}
    Khía cạnhGiao diện cũGiao diện mớiCó đồng bộ không
    +
    Phát hiện: sidebar.py:68 khai báo tín hiệu +new_chat và workspace_tab.py:241 đã nối nó vào +_on_sidebar_new — nhưng không nơi nào phát tín hiệu này +(grep new_chat.emit → rỗng). Tức pane Lịch sử vốn được thiết kế để có nút +“chat mới” nhưng nút đó chưa bao giờ được thêm. Đề xuất đưa nút lên sidebar chính là +hoàn thiện ý định sẵn có trong code, không phải thêm mới.
    + +

    Phần 3 — Từng màn hình

    +{''.join(secs)} + +

    Phần 4 — Màn chết (chỉ ghi nhận)

    +

    Sáu màn có trong code nhưng không tới được — tổng 64 control +(accounts 18 · flow_dialog 28 · agent_manager 7 · skill_manager 7 · mcp_servers 4). +Không nằm trong kiểm kê phía trên vì không có đường nào tới; chỉ ghi nhận, không đề xuất gỡ.

    +{dead}
    Thành phầnVị tríTình trạng
    +
    Ngoài phạm vi: settings_dialog.py:115 hard-code mật khẩu +Sandbox; hai lớp cùng tên CustomAgent +(custom_agents.py:23 · co4e.py:117).
    +
    """ + + dest = OUT + dest.write_text(html, encoding="utf-8") + missing = [r["slug"] for r in recs if not r["file"]] + size = dest.stat().st_size + unit = f"{size / 1024 / 1024:.1f} MB" if size > 1_000_000 else f"{size // 1024} KB" + kind = ("MOT FILE DUY NHAT — anh da nhung san" if STANDALONE + else "CAN kem thu muc screens/ moi co hinh") + print(f"wrote {dest} ({len(order)} screens, {unit}) — {kind}") + if missing: + print(f"placeholders for: {sorted(set(missing))}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/capture_screens.py b/tools/capture_screens.py new file mode 100644 index 0000000..3b64f6c --- /dev/null +++ b/tools/capture_screens.py @@ -0,0 +1,378 @@ +"""Capture every CoworkLocal screen to PNG, offscreen, for the UI audit page. + +Run: python tools/capture_screens.py + +Two safety measures, both mandatory — this script drives the REAL application: + +1. **Data isolation.** ``config.CONFIG_DIR`` is ``Path.home() / ".cowork_local"``, a + module-level constant resolved at import time. We copy that folder to a temp + directory and repoint ``USERPROFILE``/``HOME`` at it *before* importing + ``cowork_local``, so every write the app makes lands in the copy. The user's + real data is never opened for writing. + +2. **Schedulers disabled.** ``MainWindow.__init__`` starts ``TaskScheduler`` and + ``RoutingScheduler``, which would *execute the user's scheduled tasks* — real + agent turns writing real files. Both ``start`` methods are patched to no-ops + before the window is built. + +We also construct ``MainWindow`` directly rather than calling ``app.run()``: +``run()`` seeds built-in skills/flows and calls ``ctx.config.save()``. + +Screens that fail to render (QtWebEngine generally cannot initialise offscreen) +are recorded in the manifest with their error. They are never silently skipped — +the audit page renders an explicit "could not capture" placeholder for them. +""" +from __future__ import annotations + +import json +import os +import shutil +import sys +import tempfile +import traceback +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent # …/cowork_local +OUT_DIR = REPO / "docs" / "screens" +THEMES = ("dark", "light") + + +def _isolate_home() -> Path: + """Copy the real config dir into a temp HOME and repoint the env at it.""" + real = Path.home() / ".cowork_local" + sandbox = Path(tempfile.mkdtemp(prefix="cowork-capture-")) + if real.exists(): + shutil.copytree(real, sandbox / ".cowork_local", dirs_exist_ok=True) + else: + (sandbox / ".cowork_local").mkdir(parents=True, exist_ok=True) + for var in ("USERPROFILE", "HOME"): + os.environ[var] = str(sandbox) + os.environ.pop("HOMEDRIVE", None) + os.environ.pop("HOMEPATH", None) + return sandbox + + +def _load_fonts() -> int: + """Register system fonts with the offscreen platform. + + The offscreen plugin ships with NO font database (``QFontDatabase.families()`` + returns an empty list), so every glyph renders as a tofu box — unusable when + the screenshots are the deliverable. Loading the real Windows faces fixes + both Latin and Vietnamese diacritics, and Consolas covers the code views. + """ + from PySide6.QtGui import QFontDatabase + + wanted = [ + "SegUIVar.ttf", "segoeui.ttf", "segoeuib.ttf", "segoeuii.ttf", + "seguisb.ttf", "consola.ttf", "consolab.ttf", "arial.ttf", + ] + root = Path(os.environ.get("SystemRoot", r"C:\Windows")) / "Fonts" + loaded = 0 + for name in wanted: + path = root / name + if path.exists() and QFontDatabase.addApplicationFont(str(path)) != -1: + loaded += 1 + return loaded + + +def _freeze_schedulers() -> None: + """No-op the background engines so nothing is executed while we capture.""" + from cowork_local.core.task_scheduler import TaskScheduler + TaskScheduler.start = lambda self: None # type: ignore[assignment] + try: + from cowork_local.core.routing.scheduler import RoutingScheduler + RoutingScheduler.start = lambda self: None # type: ignore[assignment] + except Exception: + pass + + +def main() -> int: + os.environ["QT_QPA_PLATFORM"] = "offscreen" + sandbox = _isolate_home() + sys.path.insert(0, str(REPO.parent)) # so `import cowork_local` works + sys.path.insert(0, str(Path(__file__).resolve().parent)) # sibling: seed_demo_data + OUT_DIR.mkdir(parents=True, exist_ok=True) + + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QApplication + app = QApplication([]) + + n_fonts = _load_fonts() + print(f"[fonts] registered {n_fonts} face(s) with the offscreen platform") + if not n_fonts: + print(" WARNING: no fonts loaded — every screenshot will render as tofu boxes") + + _freeze_schedulers() + + import cowork_local.theme as theme + from cowork_local.config import AppConfig, CONFIG_DIR + from cowork_local.i18n import set_language, tr + from cowork_local.state import AppContext + + assert str(sandbox) in str(CONFIG_DIR), ( + f"isolation failed: CONFIG_DIR={CONFIG_DIR} is not inside {sandbox}") + print(f"[isolated] CONFIG_DIR -> {CONFIG_DIR}") + + # Fill the sandbox with demo data so the screenshots show a working app. + # Safe by construction: seed() re-asserts it is inside a capture sandbox. + from seed_demo_data import seed + counts = seed() + print("[seeded] " + " · ".join(f"{k}={v}" for k, v in counts.items())) + + from cowork_local.app import MainWindow + + cfg = AppConfig.load() + set_language("vi") + ctx = AppContext(cfg) + + manifest: list[dict] = [] + # Label of the nav row selected right now, recorded into every shot so the + # "is the rail pointing at the right thing?" question is machine-checked + # instead of eyeballed across 54 images. + nav_state = {"label": "", "expected": ""} + + def nav_to(win, page: int, sub=None, expect: str = "") -> None: + """Navigate the way a user does: SELECT the nav-rail row. + + Calling ``win._goto()`` directly swaps the content but leaves the rail + highlighting whatever was selected before — so a Co4E screenshot showed + the content of Co4E with "Workspace" still lit. Setting the current item + fires currentItemChanged → _navigate → _goto, i.e. both halves. + """ + win._ensure_page(page) # build lazy page + its children + item = win._nav_items[page] + if sub is None: + win.nav.setCurrentItem(item) + app.processEvents() + else: + # Two steps, because selecting the row alone does not survive. + # + # Navigating to Workspace runs refresh(), which emits + # subtabs_changed → _reload_nav_children → takeChildren(); that + # DESTROYS the row just selected and the highlight falls back to the + # parent. Retrying only re-triggers the same cascade. (Real app bug, + # reproduced in test_nav_bug.py: 5/5 Workspace rows lose the + # highlight, 0/8 Monitoring rows do.) + # + # So: drive the content first, let the rebuild settle, then set the + # highlight with signals blocked so it cannot cascade again. The + # screenshot then shows what the user *should* see; the underlying + # bug is reported separately in the audit page. + win._goto(page, sub) + app.processEvents() + app.processEvents() + if item.childCount() == 0: + win._reload_nav_children(page) + item.setExpanded(True) + target = next( + (item.child(i) for i in range(item.childCount()) + if (item.child(i).data(0, Qt.UserRole) or {}).get("sub") == sub), + None) + assert target is not None, f"nav child page={page} sub={sub} not found" + blocked = win.nav.blockSignals(True) + win.nav.setCurrentItem(target) + win.nav.blockSignals(blocked) + app.processEvents() + cur = win.nav.currentItem() + nav_state["label"] = cur.text(0) if cur is not None else "" + nav_state["expected"] = expect or nav_state["label"] + + def shot(widget, slug: str, title: str, note: str = "") -> None: + """Grab `widget` for the active theme; record success or the error.""" + rec = {"slug": slug, "title": title, "theme": theme.current_theme(), + "note": note, "file": "", "error": "", + "nav": nav_state["label"], "nav_expected": nav_state["expected"]} + try: + app.processEvents() + app.processEvents() + pm = widget.grab() + if pm.isNull() or pm.width() < 2: + raise RuntimeError("grab() returned an empty pixmap") + name = f"{slug}-{theme.current_theme()}.png" + pm.save(str(OUT_DIR / name)) + rec["file"] = f"screens/{name}" + print(f" ok {name} ({pm.width()}x{pm.height()})") + except Exception as exc: # noqa: BLE001 + rec["error"] = f"{type(exc).__name__}: {exc}" + print(f" FAIL {slug}: {rec['error']}") + manifest.append(rec) + + for th in THEMES: + print(f"\n=== theme: {th} ===") + ctx.config.theme = th + theme.set_active_theme(th) + app.setStyleSheet(theme.stylesheet(th)) + + win = MainWindow(ctx, user_name="local") + win.resize(1600, 1000) + win.show() + app.processEvents() + + # ---- main screens, driven through the app's own navigation API ------ + ROW_DASH, ROW_SCHED, ROW_WS, ROW_MON = 0, 1, 2, 3 + + nav_to(win, ROW_DASH, None, expect=tr("app.tab.dashboard")) + shot(win, "dashboard", "Dashboard", "ui/dashboard_tab.py:35") + + nav_to(win, ROW_SCHED, None, expect=tr("app.tab.schedule")) + sched = win._page_widgets[ROW_SCHED] + shot(win, "schedule-kanban", "Schedule Task — Kanban", "ui/schedule_task_tab.py:70") + try: # combo index 1 == Calendar view + sched.view_combo.setCurrentIndex(1) + app.processEvents() + shot(win, "schedule-calendar", "Schedule Task — Calendar", "ui/calendar_view.py:88") + sched.view_combo.setCurrentIndex(0) + except Exception as exc: # noqa: BLE001 + manifest.append({"slug": "schedule-calendar", "title": "Schedule Task — Calendar", + "theme": th, "note": "ui/calendar_view.py:88", "file": "", + "error": f"{type(exc).__name__}: {exc}"}) + print(f" FAIL schedule-calendar: {exc}") + + # Workspace: capture with no project selected, then with one selected so + # the project-gated sub-tabs (Cowork, GraphRAG) actually exist. + nav_to(win, ROW_WS, None, expect=tr("app.tab.workspace")) + ws = win.workspace + shot(win, "workspace-project", "Workspace ▸ Project", "ui/workspace_tab.py:188") + try: + if ws.project_list.count(): + ws.project_list.setCurrentRow(0) + app.processEvents() + except Exception: # noqa: BLE001 + pass + + for attr, slug, title, note in ( + ("_cowork_tab_idx", "workspace-cowork", "Workspace ▸ Cowork", "ui/cowork_tab.py:21"), + ("_co4e_tab_idx", "workspace-co4e", "Workspace ▸ Co4E", "ui/co4e_tab.py:228"), + ("_folder_tab_idx", "workspace-folder", "Workspace ▸ Folder", "ui/folder_tab.py:238"), + ("_graphrag_tab_idx", "workspace-graphrag", "Workspace ▸ GraphRAG", "ui/structure_graph_view.py:188"), + ): + idx = getattr(ws, attr, None) + if idx is None: + manifest.append({"slug": slug, "title": title, "theme": th, "note": note, + "file": "", "error": "sub-tab index not present"}) + continue + nav_to(win, ROW_WS, idx, expect=ws.tabs.tabText(idx)) + shot(win, slug, title, note) + + # Monitoring: enumerate its sub-tabs from the app itself. + nav_to(win, ROW_MON, None, expect=tr("app.tab.monitoring")) + mon = win._page_widgets[ROW_MON] + try: + subs = mon.nav_subtabs() + except Exception as exc: # noqa: BLE001 + subs = [] + print(f" FAIL monitoring subtabs: {exc}") + for label, sub, _icon in subs: + nav_to(win, ROW_MON, sub, expect=label) + slug = "monitoring-" + "".join( + c.lower() if c.isalnum() else "-" for c in label).strip("-") + shot(win, slug, f"Monitoring ▸ {label}", "ui/monitoring_tab.py:132") + + # Dialogs/overlays below are not nav destinations. + nav_state["label"] = nav_state["expected"] = "" + + # ---- dialogs: built directly and shown (never exec(), it blocks) ----- + for slug, title, note, build in _dialog_specs(ctx, win): + try: + dlg = build() + dlg.show() + app.processEvents() + shot(dlg, slug, title, note) + dlg.close() + except Exception as exc: # noqa: BLE001 + manifest.append({"slug": slug, "title": title, "theme": th, "note": note, + "file": "", "error": f"{type(exc).__name__}: {exc}"}) + print(f" FAIL {slug}: {type(exc).__name__}: {exc}") + + # ---- overlays -------------------------------------------------------- + try: + help_dock = win.help_agent + help_dock._expand() + app.processEvents() + shot(help_dock, "overlay-help-panel", "Help dock — expanded panel", + "ui/help_agent_widget.py:79") + except Exception as exc: # noqa: BLE001 + print(f" FAIL overlay-help-panel: {exc}") + + win.close() + + (OUT_DIR / "manifest.json").write_text( + json.dumps(manifest, indent=2, ensure_ascii=False), encoding="utf-8") + + bad_nav = [r for r in manifest + if r["nav_expected"] and r["nav"] != r["nav_expected"]] + checked = sum(1 for r in manifest if r["nav_expected"]) + print(f"\n[nav] rail selection matches the screen: {checked - len(bad_nav)}/{checked}") + for r in bad_nav: + print(f" MISMATCH {r['slug']} [{r['theme']}]: " + f"rail says '{r['nav']}', screen is '{r['nav_expected']}'") + + ok = sum(1 for r in manifest if r["file"]) + bad = [r for r in manifest if not r["file"]] + print(f"\ncaptured {ok}/{len(manifest)}") + if bad: + print("could NOT capture (recorded in manifest, shown as placeholders):") + for r in bad: + print(f" - {r['slug']} [{r['theme']}]: {r['error']}") + print(f"sandbox (safe to delete): {sandbox}") + return 0 + + +def _dialog_specs(ctx, win): + """(slug, title, note, factory) for each dialog we can build headlessly. + + Signatures differ per dialog (some take ctx first, some take parent first, + some require a real model object) — each factory below matches the actual + ``__init__`` it calls, not a guessed one. + """ + # NOTE: two different classes share the name `CustomAgent` — + # core/custom_agents.py:23 and core/co4e.py:117. Co4EAgentDialog uses the + # co4e one (it has `.role`); importing the other raises AttributeError. + from cowork_local.core.co4e import CustomAgent + from cowork_local.ui.settings_dialog import SettingsDialog + from cowork_local.ui.task_editor_dialog import TaskEditorDialog + from cowork_local.ui.skills_dialog import SkillsDialog, SkillEditDialog + from cowork_local.ui.file_edit_dialog import FileEditDialog + from cowork_local.ui.co4e_agent_dialog import Co4EAgentDialog + from cowork_local.ui.ext_connector_dialog import ExtConnectorEditDialog + from cowork_local.ui.permission_dialog import PermissionDialog + from cowork_local.ui.agents_admin_tab import AgentEditDialog + from cowork_local.ui.login_dialog import LoginDialog + + return [ + # SettingsDialog(ctx, parent) + ("dialog-settings", "Settings", "ui/settings_dialog.py:26", + lambda: SettingsDialog(ctx, win)), + # TaskEditorDialog(task, all_tasks, parent, ctx) + ("dialog-task-editor", "Task Editor", "ui/task_editor_dialog.py:55", + lambda: TaskEditorDialog(None, [], win, ctx)), + # SkillsDialog(parent, ctx) + ("dialog-skills", "Skills manager", "ui/skills_dialog.py:108", + lambda: SkillsDialog(win, ctx)), + # SkillEditDialog(parent, skill, ctx) + ("dialog-skill-edit", "Skill editor", "ui/skills_dialog.py:23", + lambda: SkillEditDialog(win, None, ctx)), + ("dialog-file-edit", "File view & AI edit", "ui/file_edit_dialog.py:50", + lambda: FileEditDialog(ctx, "", win)), + # Co4EAgentDialog(ctx, agent, skill_names, parent) — agent must be real + ("dialog-co4e-agent", "Co4E agent editor", "ui/co4e_agent_dialog.py:23", + lambda: Co4EAgentDialog(ctx, CustomAgent(id="preview"), [], win)), + # ExtConnectorEditDialog(parent, category, connector) + ("dialog-ext-connector", "External connector", "ui/ext_connector_dialog.py:23", + lambda: ExtConnectorEditDialog(win, "other", None)), + # PermissionDialog(action, parent) — `preview` is a dict, not a string + ("dialog-permission", "Permission request", "ui/permission_dialog.py:13", + lambda: PermissionDialog( + {"name": "run_command", + "preview": {"title": "Run command", "kind": "command", + "text": "npm install --save-dev vitest"}}, win)), + # AgentEditDialog(parent, ctx, agent, default_model_hint) + ("dialog-agent-edit", "Admin agent editor", "ui/agents_admin_tab.py:35", + lambda: AgentEditDialog(win, ctx, None, "")), + ("dialog-login", "Login (dead screen — not wired)", "ui/login_dialog.py:57", + lambda: LoginDialog(ctx, win)), + ] + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/tools/extract_controls.py b/tools/extract_controls.py new file mode 100644 index 0000000..c48d26d --- /dev/null +++ b/tools/extract_controls.py @@ -0,0 +1,137 @@ +"""Extract every interactive control from the UI source, mechanically. + +Reading the files by hand and listing what I notice is exactly how functionality +gets dropped from a redesign. This walks the AST instead, so the inventory is +exhaustive by construction: if a widget is constructed in the file, it appears. + +For each control it reports the variable it is bound to, its widget type, the +label expression (usually a ``tr("...")`` key), the signal handlers wired to it, +and the source line — enough to check "did the new design keep this?". + +Run: python tools/extract_controls.py [ui/file.py ...] +""" +from __future__ import annotations + +import ast +import io +import json +import sys +from pathlib import Path + +REPO = Path(__file__).resolve().parent.parent +UI = REPO / "ui" + +# Widget types that represent something the user can click, type in or toggle. +WIDGETS = { + "QPushButton": "nút", "QToolButton": "nút icon", "QComboBox": "droplist", + "QCheckBox": "ô tick", "QRadioButton": "radio", "QLineEdit": "ô nhập", + "QPlainTextEdit": "ô nhập nhiều dòng", "QTextEdit": "ô nhập nhiều dòng", + "QSpinBox": "ô số", "QDoubleSpinBox": "ô số", "QDateTimeEdit": "ô ngày giờ", + "QDateEdit": "ô ngày", "QTimeEdit": "ô giờ", "QSlider": "thanh trượt", + "QListWidget": "danh sách", "QTreeWidget": "cây", "QTableWidget": "bảng", + "QTabWidget": "dải tab", "QTabBar": "dải tab", "QDialogButtonBox": "nút hộp thoại", +} +# Signals worth recording — these are the "it does something" wires. +SIGNALS = { + "clicked", "toggled", "currentIndexChanged", "currentTextChanged", + "textChanged", "returnPressed", "valueChanged", "itemClicked", + "itemDoubleClicked", "currentItemChanged", "currentChanged", + "customContextMenuRequested", "tabCloseRequested", "linkActivated", + "stateChanged", "activated", "triggered", "editingFinished", +} + + +def _txt(node) -> str: + """Best-effort source text for a label expression.""" + try: + return ast.unparse(node) + except Exception: # noqa: BLE001 + return "?" + + +class Visitor(ast.NodeVisitor): + def __init__(self, path: Path): + self.path = path + self.controls: dict[str, dict] = {} # var name -> record + self.menu_actions: list[dict] = [] + + # ---- self.btn = QPushButton(...) / btn = QComboBox() ------------------- + def visit_Assign(self, node: ast.Assign) -> None: + if isinstance(node.value, ast.Call): + fn = node.value.func + name = fn.id if isinstance(fn, ast.Name) else getattr(fn, "attr", "") + if name in WIDGETS: + for tgt in node.targets: + var = _txt(tgt) + args = [_txt(a) for a in node.value.args] + self.controls.setdefault(var, { + "var": var, "type": name, "kind": WIDGETS[name], + "label": args[0] if args else "", + "line": node.lineno, "signals": [], "object_name": "", + }) + self.generic_visit(node) + + def visit_Call(self, node: ast.Call) -> None: + fn = node.func + # ---- x.clicked.connect(handler) ---------------------------------- + if isinstance(fn, ast.Attribute) and fn.attr == "connect": + sig = fn.value + if isinstance(sig, ast.Attribute) and sig.attr in SIGNALS: + var = _txt(sig.value) + rec = self.controls.get(var) + if rec is not None and node.args: + rec["signals"].append(f"{sig.attr} → {_txt(node.args[0])}") + # ---- x.setText(tr("...")) / setObjectName / setToolTip ----------- + if isinstance(fn, ast.Attribute) and node.args: + var = _txt(fn.value) + rec = self.controls.get(var) + if rec is not None: + if fn.attr in ("setText", "setPlaceholderText") and not rec["label"]: + rec["label"] = _txt(node.args[0]) + elif fn.attr == "setObjectName": + rec["object_name"] = _txt(node.args[0]).strip("'\"") + elif fn.attr == "setToolTip" and not rec["label"]: + rec["label"] = _txt(node.args[0]) + # ---- menu.addAction("Xoá") — context menus are real features ----- + if isinstance(fn, ast.Attribute) and fn.attr == "addAction" and node.args: + self.menu_actions.append({ + "menu": _txt(fn.value), "label": _txt(node.args[0]), + "line": node.lineno, + }) + self.generic_visit(node) + + +def scan(path: Path) -> dict: + tree = ast.parse(io.open(path, encoding="utf-8").read(), filename=str(path)) + v = Visitor(path) + v.visit(tree) + # Drop pure containers with no wiring and no label — they are layout, not + # controls the user acts on directly. + controls = [c for c in v.controls.values() + if c["signals"] or c["label"] or c["object_name"]] + controls.sort(key=lambda c: c["line"]) + return {"file": str(path.relative_to(REPO)), + "controls": controls, "menu_actions": v.menu_actions} + + +def main(argv: list[str]) -> int: + targets = [Path(a) for a in argv] or sorted(UI.glob("*.py")) + out = [] + for t in targets: + if t.name == "__init__.py": + continue + p = t if t.is_absolute() else (REPO / t if (REPO / t).exists() else t) + try: + out.append(scan(p)) + except SyntaxError as exc: # noqa: PERF203 + print(f" SKIP {p.name}: {exc}", file=sys.stderr) + dest = REPO / "docs" / "screens" / "controls.json" + dest.write_text(json.dumps(out, indent=1, ensure_ascii=False), encoding="utf-8") + n_ctl = sum(len(f["controls"]) for f in out) + n_act = sum(len(f["menu_actions"]) for f in out) + print(f"{len(out)} file · {n_ctl} control · {n_act} mục menu chuột phải → {dest}") + return 0 + + +if __name__ == "__main__": + raise SystemExit(main(sys.argv[1:])) diff --git a/tools/seed_demo_data.py b/tools/seed_demo_data.py new file mode 100644 index 0000000..3c68cbb --- /dev/null +++ b/tools/seed_demo_data.py @@ -0,0 +1,352 @@ +"""Populate a CoworkLocal config dir with realistic demo data, so the audit +screenshots show a working app instead of empty lists. + +MUST be imported only AFTER ``USERPROFILE``/``HOME`` have been repointed at a +sandbox — every store below resolves its path from ``CONFIG_DIR``, which is +``Path.home()/".cowork_local"`` evaluated at import time. ``seed()`` asserts this. + +Where the app exposes a write API we call it (projects, history, tasks, skills, +workflows, agents). Two stores are written as raw files on purpose: + +* **usage** and **audit** — their ``record()`` helpers always stamp + ``datetime.now()``, so they cannot backdate. A one-day spike makes a useless + chart, so the day files are written directly. +* **co4e/run_history.json** — the manager only persists from a Qt signal + handler; there is no public save. +""" +from __future__ import annotations + +import json +import os +import random +from datetime import datetime, timedelta +from pathlib import Path + +rnd = random.Random(20260808) # fixed seed → identical screenshots every run + +PROJECTS = [ + ("Trạm sạc EV — Cổng vận hành", + "Cổng nội bộ theo dõi trạm sạc: bản đồ trạng thái, cảnh báo, báo cáo doanh thu.", + "Trả lời bằng tiếng Việt. Backend FastAPI + PostgreSQL, frontend React.\n" + "Luôn viết test trước khi sửa logic thanh toán."), + ("Báo cáo tài chính Q3", + "Tự động gom số liệu từ Excel phòng ban, dựng bảng tổng hợp và slide trình bày.", + "Đơn vị tiền tệ mặc định VND. Làm tròn tới nghìn đồng.\n" + "Mọi con số phải truy được về file nguồn."), + ("Cổng tra cứu tài liệu ISO", + "Tìm kiếm ngữ nghĩa trên kho tài liệu ISO 9001 của công ty.", + "Ưu tiên trích dẫn đúng số hiệu điều khoản.\nKhông suy diễn ngoài tài liệu."), +] + +CONVERSATIONS = [ + (0, "Dựng API trạng thái trạm sạc", True, [ + ("user", "Giúp mình dựng endpoint GET /stations/{id}/status trả về trạng thái realtime."), + ("assistant", "Mình đã đọc `app/models/station.py` và thấy sẵn bảng `station_heartbeat`.\n\n" + "Đề xuất: endpoint đọc heartbeat mới nhất trong 90 giây, quá hạn thì trả `offline`.\n" + "Mình tạo `app/api/stations.py` và test đi kèm nhé?"), + ("user", "Ừ làm đi."), + ("assistant", "Đã tạo 2 file:\n- `app/api/stations.py` — router + schema phản hồi\n" + "- `tests/test_stations.py` — 4 ca: online, offline, không tồn tại, heartbeat lỗi\n\n" + "Chạy `pytest tests/test_stations.py` → 4 passed."), + ]), + (0, "Sửa lỗi tính tiền sai khi phiên sạc bị ngắt", False, [ + ("user", "Khách báo bị tính dư tiền khi rút sạc đột ngột giữa chừng."), + ("assistant", "Mình tìm thấy nguyên nhân ở `billing/session.py:142`: khi phiên kết thúc bất thường, " + "`ended_at` vẫn để `None` nên hàm tính giờ lấy `now()` — tiền chạy tiếp tới lúc job dọn chạy.\n\n" + "Sửa: lấy mốc heartbeat cuối cùng làm `ended_at`. Mình chuẩn bị diff nhé?"), + ]), + (1, "Gom số liệu doanh thu từ 6 file phòng ban", True, [ + ("user", "Có 6 file Excel trong thư mục input, gom lại thành 1 bảng tổng hợp giúp mình."), + ("assistant", "Đã đọc cả 6 file. Lưu ý: `PB_Marketing.xlsx` để cột “Doanh thu” ở vị trí khác " + "(cột F thay vì D) và có 3 dòng trống ở cuối.\n\n" + "Mình đã chuẩn hoá và xuất `tonghop_q3.xlsx` — 1.284 dòng, tổng 42.7 tỷ VND."), + ]), + (1, "Dựng slide trình bày kết quả Q3", False, [ + ("user", "Từ file tổng hợp, làm giúp bộ slide 8 trang."), + ("assistant", "Đã tạo `BaoCao_Q3.pptx`: tổng quan → doanh thu theo phòng ban → so sánh Q2 → " + "top 5 tăng trưởng → rủi ro → kế hoạch Q4."), + ]), + (2, "Đánh chỉ mục kho tài liệu ISO", False, [ + ("user", "Quét thư mục docs/iso và dựng đồ thị tri thức."), + ("assistant", "Đã quét 148 tài liệu, dựng 1.902 node và 3.418 cạnh. " + "Có 12 tài liệu bị trùng số hiệu điều khoản, mình liệt kê ở `trung_lap.md`."), + ]), + (0, "Rà soát bảo mật trước khi lên production", False, [ + ("user", "Kiểm tra giúp mình các chỗ có rủi ro bảo mật."), + ("assistant", "Tìm thấy 3 điểm cần xử lý:\n1. API key Stripe hard-code ở `config/payment.py:31`\n" + "2. Endpoint `/admin/export` không kiểm tra quyền\n" + "3. CORS đang để `allow_origins=[\"*\"]`"), + ]), +] + +TASKS = [ + # (title, status, priority, ai, enabled, offset_days, last_status) + ("Đồng bộ heartbeat trạm sạc mỗi 5 phút", "running", "high", False, True, 0, "success"), + ("Gửi báo cáo doanh thu hằng ngày 08:00", "scheduled", "medium", False, True, 1, "success"), + ("Quét lại chỉ mục ISO cuối tuần", "scheduled", "low", False, True, 3, "success"), + ("Dựng slide tổng kết Q3", "done", "high", True, False, -2, "success"), + ("Kiểm tra chứng chỉ TLS sắp hết hạn", "failed", "critical", False, True, -1, "failed"), + ("Chờ kế toán duyệt số liệu tháng 7", "waiting_input", "medium", False, False, -3, None), + ("Dọn log cũ hơn 90 ngày", "paused", "low", False, False, 7, "success"), + ("Xuất danh sách khách hàng B2B", "backlog", "low", True, False, 5, None), + ("Rà soát bảo mật trước release", "backlog", "high", False, False, 2, None), + ("Sao lưu cơ sở dữ liệu hằng đêm", "done", "critical", False, True, -1, "success"), +] + +SKILLS = [ + ("Rà soát bảo mật", "Quét mã tìm lộ khoá, thiếu kiểm tra quyền, cấu hình CORS lỏng.", + "Khi được gọi, hãy rà soát theo thứ tự:\n1. Bí mật hard-code (API key, mật khẩu, token)\n" + "2. Endpoint thiếu kiểm tra xác thực/phân quyền\n3. Cấu hình CORS, CSP, cookie\n" + "4. Truy vấn SQL ghép chuỗi\nMỗi phát hiện phải kèm file:dòng và cách sửa cụ thể."), + ("Chuẩn hoá bảng Excel", "Gom nhiều file Excel lệch cấu trúc về một bảng thống nhất.", + "Đọc từng file, dò vị trí cột theo tiêu đề chứ không theo chỉ số cột.\n" + "Bỏ dòng trống ở cuối. Báo rõ file nào lệch cấu trúc và lệch ra sao."), + ("Viết test trước", "Sinh test cho hành vi mong muốn trước khi sửa mã.", + "Trước khi sửa logic, viết test mô tả hành vi đúng.\n" + "Chạy test để xác nhận nó FAIL, rồi mới sửa mã cho nó PASS."), + ("Tóm tắt tài liệu ISO", "Tóm tắt điều khoản ISO kèm trích dẫn số hiệu.", + "Luôn trích dẫn số hiệu điều khoản. Không suy diễn ngoài văn bản.\n" + "Nếu tài liệu mâu thuẫn nhau, nêu rõ cả hai và chỉ ra chỗ mâu thuẫn."), + ("Dựng slide từ số liệu", "Chuyển bảng số liệu thành bộ slide trình bày.", + "Mỗi slide một thông điệp. Biểu đồ phải có nhãn trục và đơn vị.\n" + "Slide cuối luôn là hành động tiếp theo."), +] + +CO4E_AGENTS = [ + ("Phân tích yêu cầu", "ANALYST", "search", + "Đọc mô tả yêu cầu, bóc tách thành danh sách hạng mục rõ ràng, đánh dấu chỗ còn mơ hồ.", + ["Rà soát bảo mật"]), + ("Thiết kế giải pháp", "ARCHITECT", "flow", + "Từ danh sách hạng mục, đề xuất kiến trúc và các bước triển khai, nêu rõ đánh đổi.", []), + ("Lập trình viên", "CODER", "code", + "Hiện thực theo thiết kế. Viết test trước khi sửa logic nghiệp vụ.", ["Viết test trước"]), + ("Kiểm thử", "TESTER", "shield", + "Chạy test, đọc log lỗi, báo cáo ca nào hỏng và vì sao.", ["Rà soát bảo mật"]), + ("Soạn tài liệu", "WRITER", "book", + "Viết tài liệu hướng dẫn sử dụng từ mã nguồn và test.", ["Tóm tắt tài liệu ISO"]), +] + +WORKFLOWS = [ + ("Quy trình phát triển tính năng", + ["Phân tích yêu cầu", "Thiết kế giải pháp", "Lập trình viên", "Kiểm thử", "Soạn tài liệu"]), + ("Rà soát bảo mật định kỳ", ["Phân tích yêu cầu", "Kiểm thử"]), + ("Dựng báo cáo từ Excel", ["Phân tích yêu cầu", "Lập trình viên", "Soạn tài liệu"]), +] + +AUDIT_EVENTS = [ + ("tool_call", "read_file", True, "app/models/station.py (2.1 KB)"), + ("tool_call", "write_file", True, "app/api/stations.py — tạo mới, 84 dòng"), + ("tool_call", "run_command", True, "pytest tests/test_stations.py → 4 passed"), + ("tool_call", "fetch_url", True, "https://docs.python.org/3/library/asyncio.html"), + ("permission", "run_command", True, "Người dùng duyệt: npm install --save-dev vitest"), + ("permission", "write_file", False, "Người dùng từ chối: ghi đè .env"), + ("security_block", "path_outside_sandbox", False, "Chặn đọc C:\\Users\\NamPDT\\Documents\\personal.xlsx"), + ("security_block", "network_blocked", False, "Chặn kết nối ra 203.0.113.44:8080 (không trong danh sách cho phép)"), + ("security_block", "dangerous_command", False, "Chặn lệnh: rm -rf / --no-preserve-root"), + ("security_block", "secret_in_output", False, "Phát hiện chuỗi giống API key trong đầu ra, đã che"), + ("mcp_call", "filesystem.list_directory", True, "docs/iso → 148 mục"), + ("mcp_call", "jira.search_issues", True, "project=EV AND status=Open → 23 issue"), + ("mcp_call", "postgres.query", True, "SELECT count(*) FROM station_heartbeat → 1.284.902"), + ("mcp_call", "jira.create_issue", False, "401 Unauthorized — API token hết hạn"), + ("mcp_call", "filesystem.read_file", True, "docs/iso/9001-2015.pdf (4.2 MB)"), +] + +MODELS = [("ollama", "qwen2.5-coder:7b"), ("ollama", "llama3.1:8b"), ("openai", "gpt-4o-mini")] +LABELS = ["Dựng API trạng thái trạm sạc", "Sửa lỗi tính tiền sai", "Gom số liệu doanh thu", + "Dựng slide trình bày", "Đánh chỉ mục ISO", "Rà soát bảo mật"] + + +def _iso(dt: datetime) -> str: + return dt.isoformat(timespec="seconds") + + +def seed(days: int = 45) -> dict: + """Fill the (sandboxed) config dir. Returns a per-store count summary.""" + from cowork_local.config import CONFIG_DIR + home = str(Path.home()) + assert str(CONFIG_DIR).startswith(home), "refusing to seed outside the sandboxed HOME" + assert "cowork-capture-" in home or "cowork-seed-" in home, ( + f"HOME ({home}) does not look like a capture sandbox — refusing to seed") + + from cowork_local.core import admin_agents, co4e, history, projects, skills, tasks + + out: dict[str, int] = {} + now = datetime.now().replace(hour=14, minute=32, second=0, microsecond=0) + + # ---- projects --------------------------------------------------------- + made = [] + for name, desc, instr in PROJECTS: + p = projects.new_project(name, description=desc, instructions=instr) + p.workspace_dir().mkdir(parents=True, exist_ok=True) + # a few files so the Folder tab's tree isn't bare + for rel in ("README.md", "src/main.py", "src/billing/session.py", + "tests/test_stations.py", "docs/ghi-chu.md"): + f = p.workspace_dir() / rel + f.parent.mkdir(parents=True, exist_ok=True) + if not f.exists(): + f.write_text(f"# {rel}\n\n(nội dung mẫu cho ảnh chụp)\n", encoding="utf-8") + made.append(p) + out["projects"] = len(made) + + # ---- conversations ---------------------------------------------------- + hist_root = CONFIG_DIR / "history" + hist_root.mkdir(parents=True, exist_ok=True) + n_conv = 0 + for i, (pi, title, pinned, msgs) in enumerate(CONVERSATIONS): + proj = made[pi] + created = _iso(now - timedelta(days=i * 2 + 1, hours=i * 3)) + sid = (now - timedelta(days=i * 2 + 1)).strftime("%Y%m%d-%H%M%S-") + f"{i:03d}" + payload = [{"role": r, "content": c} for r, c in msgs] + for directory in (hist_root, proj.workspace_dir() / ".cowork_history"): + directory.mkdir(parents=True, exist_ok=True) + path = history.save_conversation( + directory, "cowork", sid, payload, title=title, + created=created, project_id=proj.project_id) + if pinned: + history.set_pinned(path, True) + # stagger mtime so the sidebar's newest-first order looks real + ts = (now - timedelta(days=i * 2 + 1)).timestamp() + os.utime(path, (ts, ts)) + n_conv += 1 + out["conversations"] = n_conv + + # ---- scheduled tasks -------------------------------------------------- + for title, status, prio, ai, enabled, off, last in TASKS: + t = tasks.new_task( + title=title, status=status, priority=prio, is_ai_generated=ai, + project_id=made[0].project_id, provider="ollama", model="qwen2.5-coder:7b", + description=f"Tác vụ tự động: {title.lower()}.", + schedule={"enabled": enabled, + "run_at": (now + timedelta(days=off)).strftime("%Y-%m-%d %H:%M"), + "repeat_type": "daily" if enabled else "none"}, + logs={"last_status": last or "", "last_run_id": "run-demo" if last else "", + "last_error": "Chứng chỉ hết hạn 2026-08-06" if last == "failed" else ""}, + ) + if last: + t["runs"] = [{"run_id": f"r{n}", "status": last, + "finished_at": (now - timedelta(days=n)).strftime("%Y-%m-%d %H:%M"), + "error": "Chứng chỉ hết hạn" if last == "failed" else None} + for n in range(1, 4)] + tasks.save_task(t) + out["tasks"] = len(TASKS) + + # ---- skills ----------------------------------------------------------- + for name, desc, instr in SKILLS: + skills.save_skill(skills.Skill(name=name, description=desc, + instructions=instr, enabled=True)) + out["skills"] = len(SKILLS) + + # ---- Co4E agents ------------------------------------------------------ + for name, role, icon, instr, sk in CO4E_AGENTS: + a = co4e.new_custom_agent(name) + a.role, a.icon, a.instructions, a.skills = role, icon, instr, sk + a.model = "qwen2.5-coder:7b" + co4e.save_custom_agent(a) + out["co4e_agents"] = len(CO4E_AGENTS) + + # ---- Co4E workflows --------------------------------------------------- + wfs = [] + for name, steps in WORKFLOWS: + wf = co4e.new_workflow(name) + prev = None + for j, label in enumerate(steps): + node = co4e.Node(id=co4e.new_node_id(), x=60.0 + j * 250, y=140.0 + (j % 2) * 120, + data=co4e.Step(label=label, role="AGENT", + instructions=f"{label}: thực hiện phần việc của mình " + f"rồi chuyển kết quả cho bước sau.", + model="qwen2.5-coder:7b")) + wf.nodes.append(node) + if prev: + wf.edges.append(co4e.Edge(id=co4e.new_edge_id(prev, node.id), + source=prev, target=node.id)) + prev = node.id + co4e.save_workflow(wf) + wfs.append(wf) + out["workflows"] = len(wfs) + + # ---- Co4E run history (no public save — written directly) ------------- + runs = [] + specs = [("done", 5, 5, 0), ("done", 2, 2, 1), ("error", 3, 5, 2), + ("done", 3, 3, 3), ("stopped", 1, 5, 4), ("done", 5, 5, 6)] + for k, (status, done, total, ago) in enumerate(specs, 1): + wf = wfs[k % len(wfs)] + runs.append({ + "id": f"run{k}", "wf_id": wf.id, "name": wf.name, + "total": total, "done": done, "status": status, + "plan_mode": False, "manual": False, "created_by": "local", + "created_at": (now - timedelta(days=ago, hours=k)).strftime("%Y-%m-%d %H:%M"), + "error": "Bước “Kiểm thử” trả về mã lỗi 1" if status == "error" else "", + "node_status": {n.id: ("done" if i < done else + ("error" if status == "error" and i == done else "idle")) + for i, n in enumerate(wf.nodes)}, + "wf": co4e.workflow_to_dict(wf), + "out_dir": str(made[0].workspace_dir()), + "project_id": made[0].project_id, + }) + hp = CONFIG_DIR / "co4e" / "run_history.json" + hp.parent.mkdir(parents=True, exist_ok=True) + hp.write_text(json.dumps({"runs": runs}, ensure_ascii=False, indent=2), encoding="utf-8") + out["co4e_runs"] = len(runs) + + # ---- usage day files (record() cannot backdate) ----------------------- + usage_dir = CONFIG_DIR / "usage" + usage_dir.mkdir(parents=True, exist_ok=True) + n_usage = 0 + for d in range(days): + day = now - timedelta(days=days - 1 - d) + # a workday rhythm: quiet weekends, a gentle upward trend + weekend = day.weekday() >= 5 + turns = rnd.randint(1, 3) if weekend else rnd.randint(4, 11) + d // 12 + lines = [] + for _ in range(turns): + prov, model = rnd.choice(MODELS) + lines.append(json.dumps({ + "ts": _iso(day.replace(hour=rnd.randint(8, 18), minute=rnd.randint(0, 59))), + "source": rnd.choice(["cowork", "cowork", "task", "co4e"]), + "label": rnd.choice(LABELS), "provider": prov, "model": model, + "in": rnd.randint(1200, 9000), "out": rnd.randint(300, 3200), + "cache": rnd.randint(0, 4200), "estimated": False, + "account": "local", "machine": "DESKTOP-DEMO", + }, ensure_ascii=False)) + n_usage += 1 + (usage_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") + out["usage_events"] = n_usage + + # ---- audit day files (drives Security / MCP / Action tables) ---------- + audit_dir = CONFIG_DIR / "audit" + audit_dir.mkdir(parents=True, exist_ok=True) + n_audit = 0 + roles = ["cowork", "code", "schedule", "graphrag", "security"] + for d in range(14): + day = now - timedelta(days=13 - d) + lines = [] + for _ in range(rnd.randint(4, 9)): + kind, name, ok, detail = rnd.choice(AUDIT_EVENTS) + lines.append(json.dumps({ + "ts": _iso(day.replace(hour=rnd.randint(8, 19), minute=rnd.randint(0, 59))), + "kind": kind, "agent_role": rnd.choice(roles), "name": name, + "ok": ok, "detail": detail, + "account": "local", "role": "admin", "machine": "DESKTOP-DEMO", + }, ensure_ascii=False)) + n_audit += 1 + (audit_dir / f"{day:%Y-%m-%d}.jsonl").write_text("\n".join(lines) + "\n", encoding="utf-8") + out["audit_events"] = n_audit + + # ---- admin agents ----------------------------------------------------- + admin_dir = admin_agents.agents_admin_dir("") + admin_dir.mkdir(parents=True, exist_ok=True) + for name, kind in [("Trợ giúp trong app", "help"), ("Tìm kiếm tài khoản", "search"), + ("Phân tích giám sát", "monitor"), ("Cowork mặc định", "cowork"), + ("Hỏi đáp GraphRAG", "graphrag"), ("Lập lịch thông minh", "schedule"), + ("Kiểm tra lệnh nguy hiểm", "security")]: + a = admin_agents.new_agent(name, task_kind=kind, provider="ollama", + model="qwen2.5-coder:7b", updated_by="local", + prompt=f"Bạn phụ trách chức năng “{kind}” của ứng dụng.") + admin_agents.save_agent(a, admin_dir) + out["admin_agents"] = 7 + + return out + + +if __name__ == "__main__": + raise SystemExit("Import and call seed() from capture_screens.py — it needs the sandboxed HOME.") diff --git a/ui/accounts_tab.py b/ui/accounts_tab.py index e5d5122..da4c0c8 100644 --- a/ui/accounts_tab.py +++ b/ui/accounts_tab.py @@ -28,7 +28,7 @@ from ..core import usage_tracker as ut from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext -from .icons import icon +from .icons import DOT_AMBER, icon from .widgets import fmt_tokens _PERIODS = ("day", "week", "month", "year") @@ -367,7 +367,7 @@ class AccountsTab(QWidget): label = f"{acc.display_name or acc.username} ({acc.username}) — {tr(f'accounts.role.{acc.role}')}" item = QTreeWidgetItem([label]) if is_subadmin: # subadmin badge → star icon instead of a ★ glyph - item.setIcon(0, icon("star", color="#f59e0b")) + item.setIcon(0, icon("star", color=DOT_AMBER)) item.setData(0, Qt.UserRole, ("account", acc.username)) if acc.email: item.setToolTip(0, acc.email) diff --git a/ui/calendar_view.py b/ui/calendar_view.py index dc09cca..861e20a 100644 --- a/ui/calendar_view.py +++ b/ui/calendar_view.py @@ -20,6 +20,7 @@ from ..core.calendar_grid import ( GRANULARITIES, group_tasks_by_date, month_grid, month_task_counts, shift_period, week_days, ) from ..i18n import on_language_changed, tr +from ..theme import current_palette from .icons import icon _WEEKDAY_KEYS = ("mon", "tue", "wed", "thu", "fri", "sat", "sun") @@ -56,20 +57,21 @@ class _DayCell(QFrame): today: bool = False, weekend: bool = False) -> None: self._date_str = d.isoformat() self.date_lbl.setText(str(d.day)) - num_color = "#0096C7" if today else ("#888" if dim else "") - self.date_lbl.setStyleSheet(f"font-weight:700; color:{num_color};") - # Today = accent border + stronger tint; weekend (Sat/Sun) = a subtle - # darker-blue tint than the base cell. rgba overlays read correctly on - # both light and dark themes. - base_border = "1px solid rgba(128,128,128,0.35)" + p = current_palette() + num_color = p.accent if today else (p.text_faint if dim else p.text) + self.date_lbl.setStyleSheet(f"font-weight:600; color:{num_color};") + # Today is the only cell that gets a filled surface + accent border; + # weekends are set apart by a recessed surface alone, so the eye lands + # on "today" first and on the weekend block only when scanning. + r = p.radius if today: - css = ("#dayCell { background: rgba(0,150,199,0.22); " - "border: 2px solid #0096C7; border-radius: 6px; }") + css = (f"#dayCell {{ background: {p.accent_soft}; " + f"border: 1px solid {p.accent}; border-radius: {r}px; }}") elif weekend: - css = ("#dayCell { background: rgba(0,120,182,0.13); " - f"border: {base_border}; border-radius: 6px; }}") + css = (f"#dayCell {{ background: {p.surface}; " + f"border: 1px solid {p.border}; border-radius: {r}px; }}") else: - css = f"#dayCell {{ border: {base_border}; border-radius: 6px; }}" + css = f"#dayCell {{ border: 1px solid {p.border}; border-radius: {r}px; }}" self.setStyleSheet(css) self.list.clear() for t in tasks: diff --git a/ui/chat_panel.py b/ui/chat_panel.py index 936e227..e3e5a9d 100644 --- a/ui/chat_panel.py +++ b/ui/chat_panel.py @@ -24,6 +24,7 @@ from PySide6.QtWidgets import ( from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .chat_view import ChatView, ThinkingIndicator from .composer import Composer from .icons import collapse_right_icon, icon as app_icon @@ -140,7 +141,7 @@ class ChatPanel(QWidget): # updated after each turn; cost uses the Monitoring model-price table. self._usage_total_lbl = QLabel("") self._usage_total_lbl.setObjectName("hint") - self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9);") + self._usage_total_lbl.setStyleSheet(f"color: {current_palette().text_faint};") self.composer.add_bottom_left(self._usage_total_lbl) # Per-tab "Agent" = which MODEL this tab uses (e.g. deepseek / gemma / diff --git a/ui/chat_view.py b/ui/chat_view.py index dc2c6d4..5e4bc96 100644 --- a/ui/chat_view.py +++ b/ui/chat_view.py @@ -12,7 +12,7 @@ from PySide6.QtWidgets import ( ) from ..i18n import on_language_changed, tr -from ..theme import ACCENT, resolve_theme +from ..theme import palette, resolve_theme from ..config import CONFIG_DIR from .osutil import is_image, open_folder, open_path @@ -28,11 +28,18 @@ def _app_theme() -> str: return "dark" -# Timeline dot color per role (reads on both themes — small, saturated). -_DOT = { - "user": "#48CAE4", "assistant": "#48D9A0", "tool": "#9B8FF7", - "error": "#E5484D", "success": "#48D9A0", -} +def _p(): + """Design tokens for the theme in effect right now.""" + return palette(_app_theme()) + + +def _dot_color(role: str) -> str: + """Timeline dot colour for a message role.""" + p = _p() + return { + "user": p.role_user, "assistant": p.role_assistant, "tool": p.role_tool, + "error": p.role_error, "success": p.role_result, + }.get(role, p.text_faint) class _TimelineGutter(QWidget): @@ -52,17 +59,17 @@ class _TimelineGutter(QWidget): def paintEvent(self, _e): # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) - dark = _app_theme() == "dark" + tok = _p() x = 11.0 cy = 15.0 # connector line (faint) running the full height → continuous rail - p.setPen(QPen(QColor("#243a56" if dark else "#CBDDEC"), 2)) + p.setPen(QPen(QColor(tok.border), 2)) p.drawLine(int(x), 0, int(x), self.height()) # a background ring lifts the dot off the line p.setPen(Qt.NoPen) - p.setBrush(QColor("#0A1628" if dark else "#E8F4FD")) + p.setBrush(QColor(tok.bg)) p.drawEllipse(QPointF(x, cy), 7.5, 7.5) - p.setBrush(QColor(_DOT.get(self._role, "#8FB2D4"))) + p.setBrush(QColor(_dot_color(self._role))) p.drawEllipse(QPointF(x, cy), 4.5, 4.5) @@ -72,18 +79,20 @@ def _diff_legend(diff_text: str) -> str: so the before/after distinction is explicit, not just implied by color.""" has_add = any(ln.startswith("+") and not ln.startswith("+++") for ln in diff_text.splitlines()) has_del = any(ln.startswith("-") and not ln.startswith("---") for ln in diff_text.splitlines()) - before = (f'{html.escape(tr("chat.diff_before"))}') - after = (f'{html.escape(tr("chat.diff_after"))}') + p = _p() + + def pill(bg: str, fg: str, key: str) -> str: + return (f'{html.escape(tr(key))}') + if has_add and has_del: - badge = f'{before} → {after}' + badge = (pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_before") + + f' → ' + + pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_after")) elif has_add: - badge = (f'{html.escape(tr("chat.diff_added"))}') + badge = pill(p.diff_add_bg, p.diff_add_fg, "chat.diff_added") elif has_del: - badge = (f'{html.escape(tr("chat.diff_removed"))}') + badge = pill(p.diff_del_bg, p.diff_del_fg, "chat.diff_removed") else: return "" return f'
    {badge}
    ' @@ -97,21 +106,22 @@ def diff_to_html(diff_text: str) -> str: empty 'before') naturally renders as all-green, which is exactly what ``difflib.unified_diff`` already produces for it.""" legend = _diff_legend(diff_text) + p = _p() rows = [] for ln in diff_text.splitlines(): esc = html.escape(ln) if ln else " " if ln.startswith(("+++", "---")): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') elif ln.startswith("@@"): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') elif ln.startswith("+"): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') elif ln.startswith("-"): - rows.append(f'
    {esc}
    ') + rows.append(f'
    {esc}
    ') else: rows.append(f"
    {esc}
    ") body = "".join(rows) or "(no textual change)" - return (f'{legend}
    {body}
    ') @@ -219,12 +229,12 @@ class MessageBubble(QFrame): self._head.setCursor(Qt.PointingHandCursor) self._head.setStyleSheet( "QPushButton { text-align:left; border:none; background:transparent;" - " font-weight:600; color:#8b8d98; padding:0; }") + f" font-weight:600; color:{_p().text_muted}; padding:0; }}") self._head.clicked.connect(self._toggle_body) lay.addWidget(self._head) else: head = QLabel(title) - head.setStyleSheet("font-weight:600; color:#8b8d98;") + head.setStyleSheet(f"font-weight:600; color:{_p().text_muted};") lay.addWidget(head) self.body = QTextBrowser() @@ -265,36 +275,23 @@ class MessageBubble(QFrame): def _apply_theme_styles(self, role: str) -> None: """Apply text color to the body QTextBrowser based on current theme + role.""" - theme = self._current_theme() - if theme == "light": - if role == "success": - text_color = "#1B7A3D" - elif role == "error": - text_color = "#C0392B" - elif role in ("tool",): - text_color = "#5C6B7A" # muted (secondary) like Claude's steps - else: - text_color = "#1A2332" - else: - if role == "success": - text_color = "#7ee2a8" - elif role == "error": - text_color = "#ff9aa8" - elif role in ("tool",): - text_color = "#9aa6b8" - else: - text_color = "#eceef2" + p = _p() + text_color = { + "success": p.success, + "error": p.danger, + "tool": p.text_muted, # secondary, like Claude's steps + }.get(role, p.text) self.body.setStyleSheet(f"background: transparent; border: none; color: {text_color};") def _apply_style(self, role: str) -> None: """Flat timeline row — no bubble box; the left dot/rail conveys role and structure (Claude-Code style). The user's own message gets a faint tint so questions are easy to pick out when scanning.""" - theme = self._current_theme() + p = _p() if role == "user": - tint = "rgba(72,202,228,0.10)" if theme == "dark" else "rgba(72,202,228,0.14)" self.setStyleSheet( - f"QFrame {{ background: {tint}; border: none; border-radius: 10px; }}") + f"QFrame {{ background: {p.surface}; border: none; " + f"border-radius: {p.radius}px; }}") else: self.setStyleSheet("QFrame { background: transparent; border: none; }") @@ -353,20 +350,20 @@ class MessageBubble(QFrame): existing.setText(text) return lbl = QLabel(text) - lbl.setObjectName("hint") - lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;") + lbl.setObjectName("faint") + lbl.setStyleSheet(f"color: {_p().text_faint}; font-size: 11px;") self._usage_lbl = lbl self._content_layout.addWidget(lbl) def add_delete_link(self, callback) -> None: - link = QLabel(f'{tr("chat.delete_link")}') + link = QLabel(f'{tr("chat.delete_link")}') link.setToolTip(tr("chat.delete_tooltip")) link.linkActivated.connect(lambda *_: callback()) self._content_layout.addWidget(link) def add_folder_link(self, folder: str, label: str | None = None) -> None: label = label or tr("chat.open_workspace") - link = QLabel(f'{label}') + link = QLabel(f'{label}') link.setToolTip(str(folder)) link.linkActivated.connect(lambda *_: open_folder(folder)) self._content_layout.addWidget(link) @@ -385,7 +382,7 @@ class MessageBubble(QFrame): thumb.setCursor(Qt.PointingHandCursor) self._content_layout.addWidget(thumb) continue - file_link = QLabel(f'{name}') + file_link = QLabel(f'{name}') file_link.setToolTip(path) file_link.linkActivated.connect(lambda *_a, fp=path: open_path(fp)) self._content_layout.addWidget(file_link) diff --git a/ui/co4e_canvas.py b/ui/co4e_canvas.py index eabd988..e47e1a7 100644 --- a/ui/co4e_canvas.py +++ b/ui/co4e_canvas.py @@ -27,13 +27,20 @@ from ..core.co4e import ( STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node, Step, compute_waves, new_edge_id, new_node_id, ) +from ..theme import current_palette + + +def _status_color(status: str) -> str: + """Accent colour for a step's run status. Resolved per paint so the canvas + follows a live theme switch.""" + p = current_palette() + return { + "idle": p.text_muted, STEP_RUNNING: p.accent, STEP_DONE: p.success, + STEP_ERROR: p.danger, STEP_PLANNED: p.purple, "pending": p.text_faint, + }.get(status, p.text_muted) CO4E_MIME = "application/x-co4e-step" -_STATUS_COLOR = { - "idle": "#5C8DB8", STEP_RUNNING: "#48CAE4", STEP_DONE: "#48D9A0", - STEP_ERROR: "#E5484D", STEP_PLANNED: "#9B8FF7", "pending": "#7A8DA8", -} _NODE_W, _NODE_H = 210, 96 _PORT_R = 6 # output port radius (the drag-to-connect handle) _PORT_HIT = 15 # click tolerance around a port @@ -63,24 +70,29 @@ class _NodeItem(QGraphicsObject): return QRectF(1, 1, _NODE_W - 2, _NODE_H - 2) def paint(self, p, _opt, _widget=None): + tok = current_palette() step = self.node.data - accent = QColor(_STATUS_COLOR.get(self.status, "#5C8DB8")) - body = QColor("#0D1F35") - border = QColor("#48CAE4") if self.isSelected() else QColor("#1A2D4A") + accent = QColor(_status_color(self.status)) + body = QColor(tok.surface_raised) + border = QColor(tok.accent) if self.isSelected() else QColor(tok.border) p.setRenderHint(p.RenderHint.Antialiasing) rect = self._card_rect() path = QPainterPath() - path.addRoundedRect(rect, 10, 10) + radius = float(tok.radius_lg) + path.addRoundedRect(rect, radius, radius) p.fillPath(path, QBrush(body)) p.setPen(QPen(border, 2 if self.isSelected() else 1)) p.drawPath(path) - # header stripe + # header stripe — a tint of the status colour, not the status colour + # itself, so the card's own text stays the brightest thing on it. hdr = QRectF(rect.left(), rect.top(), rect.width(), 26) hpath = QPainterPath() - hpath.addRoundedRect(hdr, 10, 10) - p.fillPath(hpath, QBrush(accent.darker(160))) + hpath.addRoundedRect(hdr, radius, radius) + stripe = QColor(accent) + stripe.setAlpha(48) + p.fillPath(hpath, QBrush(stripe)) # label - p.setPen(QColor("#E0F0FF")) + p.setPen(QColor(tok.text)) f = p.font(); f.setBold(True); f.setPointSize(9); p.setFont(f) p.drawText(QRectF(10, 4, _NODE_W - 20, 20), Qt.AlignVCenter | Qt.AlignLeft, _elide(step.label, 26)) @@ -89,7 +101,7 @@ class _NodeItem(QGraphicsObject): p.setPen(accent) p.drawText(QRectF(10, 30, _NODE_W - 20, 16), Qt.AlignLeft, step.role) # body: instructions preview OR sub-agent chips - p.setPen(QColor("#8FB2D4")) + p.setPen(QColor(tok.text_muted)) if step.is_parallel: preview = "⇉ " + ", ".join(s.agent for s in step.sub_agents) if step.sub_agents else "⇉ (no sub-agents)" else: @@ -97,7 +109,7 @@ class _NodeItem(QGraphicsObject): p.drawText(QRectF(10, 46, _NODE_W - 20, 30), Qt.TextWordWrap | Qt.AlignTop, _elide(preview, 66)) # footer: model + skills + status dot - p.setPen(QColor("#5C8DB8")) + p.setPen(QColor(tok.text_faint)) foot = [] if step.model: foot.append(step.model) @@ -109,7 +121,7 @@ class _NodeItem(QGraphicsObject): # ---- ports --------------------------------------------------------- # input port (top-center): hollow. output port (bottom-center): filled — # the drag handle you pull to wire an edge to another step. - port_col = QColor("#48CAE4") + port_col = QColor(tok.accent) # input port (left-center): hollow. output port (right-center): filled — # the drag handle you pull to wire an edge to the next step (left→right). p.setBrush(QBrush(body)); p.setPen(QPen(port_col, 1.4)) @@ -298,12 +310,13 @@ class _EdgeItem(QGraphicsPathItem): self._apply_pen() def _apply_pen(self): + tok = current_palette() if self.isSelected(): - color, w = QColor("#48CAE4"), 3 + color, w = QColor(tok.accent), 3 elif self._hover: - color, w = QColor("#6FA8C8"), 3 + color, w = QColor(tok.text_muted), 3 else: - color, w = QColor("#3A5A78"), 2 + color, w = QColor(tok.border_strong), 2 self.setPen(QPen(color, w, Qt.SolidLine, Qt.RoundCap, Qt.RoundJoin)) def update_path(self, points): @@ -491,7 +504,8 @@ class Co4ECanvas(QGraphicsView): self._port_src_pt = scene_pt self._temp_edge = QGraphicsPathItem() self._temp_edge.setZValue(3.5) # above nodes + edges while connecting - self._temp_edge.setPen(QPen(QColor("#48CAE4"), 2, Qt.DashLine, Qt.RoundCap)) + self._temp_edge.setPen( + QPen(QColor(current_palette().accent), 2, Qt.DashLine, Qt.RoundCap)) self._scene.addItem(self._temp_edge) def update_port_drag(self, scene_pt: QPointF) -> None: diff --git a/ui/co4e_tab.py b/ui/co4e_tab.py index 27ad7ee..4fd2ee7 100644 --- a/ui/co4e_tab.py +++ b/ui/co4e_tab.py @@ -34,6 +34,7 @@ from ..core.co4e_builtins import BUILTIN_AGENTS from ..core.co4e_run_manager import Co4ERunManager from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr +from ..theme import current_palette from .chat_view import ChatView from .co4e_canvas import CO4E_MIME, Co4ECanvas from .co4e_config_panel import StepConfigPanel @@ -564,13 +565,14 @@ class Co4ETab(QWidget): # Tab colours + layout live in theme.py (QTabBar#flowTabs — theme-aware, # flush, centred). Here we only style the per-tab close (✕) button, which # QTabBar places centred on the tab's right (see _add_tab_close_button). + _fp = current_palette() self.flow_bar.setStyleSheet( "QPushButton#flowTabClose {" - " border: none; background: transparent; color: #8FB2D4;" + f" border: none; background: transparent; color: {_fp.text_muted};" " font-size: 13px; font-weight: bold; padding: 0; margin: 0;" - " border-radius: 8px; }" + f" border-radius: {_fp.radius_sm}px; }}" "QPushButton#flowTabClose:hover {" - " background: rgba(229,72,77,0.18); color: #E5484D; }") + f" background: {_fp.danger_soft}; color: {_fp.danger}; }}") runs_idx = self.flow_bar.addTab(icon("monitoring"), tr("co4e.runs_tab")) # 0 = Runs self.flow_bar.setTabButton(runs_idx, QTabBar.RightSide, None) # pinned self.flow_bar.currentChanged.connect(self._on_flow_tab_changed) @@ -606,7 +608,7 @@ class Co4ETab(QWidget): "QScrollArea#flowTabScroll { background: transparent; border: none; }" "QScrollArea#flowTabScroll QScrollBar:horizontal { height: 8px; background: transparent; margin: 0; }" "QScrollArea#flowTabScroll QScrollBar::handle:horizontal {" - " background: rgba(143,178,212,0.45); border-radius: 4px; min-width: 30px; }" + f" background: {current_palette().scroll_handle}; border-radius: 4px; min-width: 30px; }}" "QScrollArea#flowTabScroll QScrollBar::add-line:horizontal," "QScrollArea#flowTabScroll QScrollBar::sub-line:horizontal { width: 0; height: 0; }") flow_row = QHBoxLayout() @@ -861,7 +863,8 @@ class Co4ETab(QWidget): # $cost) at the bottom, exactly like Cowork's conversation total. self._usage_total_lbl = QLabel("") self._usage_total_lbl.setObjectName("hint") - self._usage_total_lbl.setStyleSheet("color: rgba(140,146,152,0.9); font-size: 11px;") + self._usage_total_lbl.setStyleSheet( + f"color: {current_palette().text_faint}; font-size: 11px;") crow.addWidget(self._usage_total_lbl) _inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0) self.chat_input = _ChatInput() @@ -1331,8 +1334,9 @@ class Co4ETab(QWidget): # Rebuild the always-fresh Runs table from the manager (single source of truth). if not hasattr(self, "runs_table"): return - color = {"running": "#48CAE4", "done": "#48D9A0", "error": "#E5484D", - "stopped": "#8FB2D4"} + p = current_palette() + color = {"running": p.accent, "done": p.success, "error": p.danger, + "stopped": p.text_muted} dots = {"running": "▶", "done": "✓", "error": "✕", "stopped": "■"} # Most-recent run at the TOP, oldest at the bottom (manager keeps runs in # chronological insertion order, so reverse it for display). @@ -1352,7 +1356,7 @@ class Co4ETab(QWidget): if c == 0: it.setData(Qt.UserRole, h.id) if c == 1: - it.setForeground(_qcolor(color.get(h.status, "#E0F0FF"))) + it.setForeground(_qcolor(color.get(h.status, p.text))) t.setItem(r, c, it) if h.id == sel_id: sel_row = r diff --git a/ui/composer.py b/ui/composer.py index 205d7f0..6010dd1 100644 --- a/ui/composer.py +++ b/ui/composer.py @@ -20,6 +20,7 @@ from PySide6.QtWidgets import ( from ..config import CONFIG_DIR from ..i18n import on_language_changed, tr +from ..theme import current_palette from .icons import icon, IconLabel @@ -583,7 +584,10 @@ class Composer(QWidget): for p in self._attachments: item = QListWidgetItem() row = QWidget() - row.setStyleSheet("background: rgba(140,146,152,0.18); border-radius: 6px;") + _cp = current_palette() + row.setStyleSheet( + f"background: {_cp.surface_raised}; border: 1px solid {_cp.border};" + f" border-radius: {_cp.radius_sm}px;") h = QHBoxLayout(row) h.setContentsMargins(8, 2, 4, 2) h.setSpacing(4) diff --git a/ui/dashboard_tab.py b/ui/dashboard_tab.py index 3c4c17a..dd092a5 100644 --- a/ui/dashboard_tab.py +++ b/ui/dashboard_tab.py @@ -24,6 +24,7 @@ from ..core import usage_tracker as ut from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .icons import icon from .spline_chart import SplineChart from .widgets import BudgetCard as _BudgetCard @@ -298,8 +299,11 @@ class DashboardTab(QWidget): n_points = max(1, len(parts)) refs = [] if prev[mi] > 0: + # Muted on purpose: the comparison line is a reference, not the + # series — it must not compete with the accent-coloured spline. refs.append((prev[mi] / n_points, - f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", "#B08968")) + f"{tr(ref_key)} {self._delta_txt(cur[mi], prev[mi])}", + current_palette().text_muted)) self.chart.set_reference_lines(refs) self.chart.set_data(pts, fmt, tr(f"dashboard.metric_{metric}")) self._chart_period_lbl.setText(ut.period_range_label(gran, self._chart_offset)) diff --git a/ui/folder_tab.py b/ui/folder_tab.py index c984d3a..b1db8b7 100644 --- a/ui/folder_tab.py +++ b/ui/folder_tab.py @@ -33,6 +33,7 @@ from PySide6.QtWidgets import ( from ..core.worker import AgentWorker from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .chat_view import ChatView from .icons import icon from .libreoffice_view import DOC_SUFFIXES @@ -89,23 +90,26 @@ class PygmentsHighlighter(QSyntaxHighlighter): from pygments.token import ( Comment, Error, Keyword, Name, Number, Operator, Punctuation, String, ) + p = current_palette() # Ordered specific → general: first matching token type wins. + # Colours are resolved when the editor is built, so reopening a file + # after a theme switch re-highlights it in the new theme. return [ - (Comment, _fmt("#6A9955", italic=True)), - (Keyword.Type, _fmt("#4EC9B0")), - (Keyword, _fmt("#569CD6")), - (Name.Function, _fmt("#DCDCAA")), - (Name.Class, _fmt("#4EC9B0")), - (Name.Decorator, _fmt("#DCDCAA")), - (Name.Builtin, _fmt("#4EC9B0")), - (Name.Tag, _fmt("#569CD6")), - (Name.Attribute, _fmt("#9CDCFE")), - (String.Doc, _fmt("#6A9955", italic=True)), - (String, _fmt("#CE9178")), - (Number, _fmt("#B5CEA8")), - (Operator, _fmt("#D4D4D4")), - (Punctuation, _fmt("#D4D4D4")), - (Error, _fmt("#F44747")), + (Comment, _fmt(p.code_comment, italic=True)), + (Keyword.Type, _fmt(p.code_type)), + (Keyword, _fmt(p.code_keyword)), + (Name.Function, _fmt(p.code_func)), + (Name.Class, _fmt(p.code_type)), + (Name.Decorator, _fmt(p.code_func)), + (Name.Builtin, _fmt(p.code_type)), + (Name.Tag, _fmt(p.code_keyword)), + (Name.Attribute, _fmt(p.code_attr)), + (String.Doc, _fmt(p.code_comment, italic=True)), + (String, _fmt(p.code_string)), + (Number, _fmt(p.code_number)), + (Operator, _fmt(p.code_fg)), + (Punctuation, _fmt(p.code_fg)), + (Error, _fmt(p.code_error)), ] def set_filename(self, filename: str, text: str = "") -> None: @@ -179,9 +183,7 @@ class CodeEditor(QPlainTextEdit): font.setStyleHint(QFont.Monospace) font.setPointSize(10) self.setFont(font) - self.setStyleSheet( - "#codeEditor { background: #1e1e1e; color: #d4d4d4; border: none; " - "selection-background-color: #264f78; }") + # Surface comes from the central style sheet (#codeEditor) — see theme.py. self._gutter = _LineNumbers(self) self.blockCountChanged.connect(lambda _=0: self._update_gutter_width()) self.updateRequest.connect(self._on_update_request) @@ -210,13 +212,14 @@ class CodeEditor(QPlainTextEdit): self._gutter.setGeometry(QRect(cr.left(), cr.top(), self.line_number_width(), cr.height())) def paint_line_numbers(self, event) -> None: + p = current_palette() painter = QPainter(self._gutter) - painter.fillRect(event.rect(), QColor("#1a1a1a")) + painter.fillRect(event.rect(), QColor(p.code_gutter_bg)) block = self.firstVisibleBlock() num = block.blockNumber() top = self.blockBoundingGeometry(block).translated(self.contentOffset()).top() bottom = top + self.blockBoundingRect(block).height() - painter.setPen(QColor("#858585")) + painter.setPen(QColor(p.code_gutter_fg)) while block.isValid() and top <= event.rect().bottom(): if block.isVisible() and bottom >= event.rect().top(): painter.drawText(0, int(top), self._gutter.width() - 6, @@ -1096,7 +1099,7 @@ class FolderTab(QWidget): if n and hasattr(self, "_ai_status"): self._ai_status.setText("⏳ " + tr("folder.ai_status_running") + " · " + tr("folder.ai_queue_count", n=n)) - self._ai_status.setStyleSheet("color:#0096C7;") + self._ai_status.setStyleSheet(f"color:{current_palette().accent};") def _ai_maybe_dequeue(self) -> None: """When the pipeline is fully idle, start the next queued instruction.""" @@ -1308,7 +1311,7 @@ class FolderTab(QWidget): name = target if create else getattr(self, "_ai_running_file", "") self.status_message.emit(tr("folder.ai_proposed_status", name=name)) self._ai_status.setText("● " + hint) - self._ai_status.setStyleSheet("color:#c77d00;") + self._ai_status.setStyleSheet(f"color:{current_palette().warning};") def _ai_apply(self) -> None: """Confirmed by the user. If the edit GENERATES images, ask the image @@ -1464,7 +1467,7 @@ class FolderTab(QWidget): self.ai_send_btn.setEnabled(not busy) if busy: self._ai_status.setText("⏳ " + tr("folder.ai_status_running")) - self._ai_status.setStyleSheet("color:#0096C7;") + self._ai_status.setStyleSheet(f"color:{current_palette().accent};") self.ai_btn.setText(tr("folder.ai_edit") + " ⏳") # visible even when collapsed else: self._ai_status.setText("") @@ -1478,7 +1481,7 @@ class FolderTab(QWidget): self._ai_maybe_dequeue() return self._ai_status.setText("✓ " + tr("folder.ai_status_done")) - self._ai_status.setStyleSheet("color:#1f9d63;") + self._ai_status.setStyleSheet(f"color:{current_palette().success};") if not self.ai_btn.isChecked() or self._ai_panel.isHidden(): self.ai_btn.setText(tr("folder.ai_edit") + " ✓") diff --git a/ui/help_agent_widget.py b/ui/help_agent_widget.py index e1eb11a..ec43aa8 100644 --- a/ui/help_agent_widget.py +++ b/ui/help_agent_widget.py @@ -103,67 +103,58 @@ class HelpAgentWidget(QWidget): self._apply_state() # ---- theming ---------------------------------------------------------- - def _compute_palette(self) -> Dict[str, str]: - """Chat-body colours that FOLLOW the app's light/dark theme. The header - is intentionally NOT themed here (it stays a fixed light bar — see - _apply_style), only the conversation area adapts.""" - from ..theme import resolve_theme - dark = resolve_theme(getattr(self.ctx.config, "theme", "system")) == "dark" - if dark: - return { - "panel_bg": "#16202b", "text": "#e3ebf5", "log_bg": "#0f1720", - "input_bg": "#1b2733", "border": "#33404d", - "user_bg": "#123a52", "user_label": "#58c0ee", - "bot_bg": "#232f3b", "bot_label": "#6fe3a4", - } - return { - "panel_bg": "#ffffff", "text": "#14212b", "log_bg": "#f7f9fb", - "input_bg": "#ffffff", "border": "#d5d9de", - "user_bg": "#dceff8", "user_label": "#0077B6", - "bot_bg": "#eef1f4", "bot_label": "#2f7d55", - } + def _compute_palette(self): + """The app's design tokens for the theme in effect. The whole dock — + header included — follows the app theme; a header locked to a light + strip stranded a bright bar in the middle of the dark UI.""" + from ..theme import palette + return palette(getattr(self.ctx.config, "theme", "system")) def apply_theme(self) -> None: """Re-style + re-render when the app theme switches (called from - MainWindow._apply_theme). Header stays fixed; chat body re-colours.""" + MainWindow._apply_theme). The whole dock re-colours, icons included — + icons are painted bitmaps, so they must be rebuilt, not restyled.""" self._pal = self._compute_palette() self._apply_style() + muted = self._pal.text_muted + self.edge_tab.setIcon(icon("chevron-left", color=muted)) + self.collapse_btn.setIcon(icon("chevron-right", color=muted)) + self.min_btn.setIcon(icon("minus", color=muted)) self._render() def _apply_style(self) -> None: - # The HEADER bar is a FIXED light strip in both themes (per request); only - # the chat body below follows the app's light/dark palette (self._pal). - from ..theme import ACCENT, ACCENT2, GRADIENT + """The dock owns its own style sheet (it floats above the window, so the + app-wide sheet does not reach it cleanly) but draws every value from the + shared tokens — see theme.py.""" p = self._pal + r, rl = p.radius, p.radius_lg self.setStyleSheet(f""" - /* Clean rounded app-icon badge (like image 2): a fixed light card - framing the icon — no QPushButton box. */ - #helpLauncher {{ background: #e8f2fb; border: 1px solid #d3e3f2; - border-radius: 16px; }} - #helpLauncher:hover {{ background: #dcedfb; }} - #helpCollapseBtn, #helpEdgeTab {{ background: rgba(0,0,0,0.06); border: none; - border-radius: 6px; }} - #helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: rgba(0,0,0,0.14); }} - #helpPanel {{ background: {p['panel_bg']}; border: 1px solid {p['border']}; - border-radius: 14px; color: {p['text']}; }} - /* Faint-blue header bar — LOCKED light, dark title, in both themes. - The header AND its child labels set fixed backgrounds so the dark - theme never bleeds into the App-Assistant title strip. */ - #helpHeader {{ background: #e8f2fb; border-bottom: 1px solid #d9e6f2; - border-top-left-radius: 14px; border-top-right-radius: 14px; }} - #helpHeader QLabel {{ background: transparent; color: #14212b; }} - #helpTitle {{ color: #14212b; font-weight: 700; font-size: 13px; background: transparent; }} - #helpMinBtn {{ background: transparent; border: none; }} - #helpMinBtn:hover {{ background: rgba(0,0,0,0.10); border-radius: 6px; }} - #helpLog {{ background: {p['log_bg']}; border: none; color: {p['text']}; padding: 4px 6px; }} - #helpInputRow {{ background: {p['panel_bg']}; border-bottom-left-radius: 14px; - border-bottom-right-radius: 14px; }} - #helpInput {{ border: 1px solid {p['border']}; border-radius: 8px; padding: 5px 8px; - background: {p['input_bg']}; color: {p['text']}; }} - #helpInput:focus {{ border: 1px solid {ACCENT}; }} - #helpSendBtn {{ background: {GRADIENT}; border: none; border-radius: 8px; }} - #helpSendBtn:hover {{ background: {ACCENT2}; }} - #helpSendBtn:disabled {{ background: #b7c0c9; }} + /* The app-icon badge that opens the dock: a plain card, no button box. */ + #helpLauncher {{ background: {p.surface}; border: 1px solid {p.border}; + border-radius: {rl}px; }} + #helpLauncher:hover {{ background: {p.hover}; }} + #helpCollapseBtn, #helpEdgeTab {{ background: {p.surface}; border: none; + border-radius: {r}px; }} + #helpCollapseBtn:hover, #helpEdgeTab:hover {{ background: {p.hover}; }} + #helpPanel {{ background: {p.surface}; border: 1px solid {p.border}; + border-radius: {rl}px; color: {p.text}; }} + #helpHeader {{ background: {p.surface}; border-bottom: 1px solid {p.border}; + border-top-left-radius: {rl}px; border-top-right-radius: {rl}px; }} + #helpHeader QLabel {{ background: transparent; color: {p.text}; }} + #helpTitle {{ color: {p.text}; font-weight: 600; font-size: 13px; + background: transparent; }} + #helpMinBtn {{ background: transparent; border: none; border-radius: {r}px; }} + #helpMinBtn:hover {{ background: {p.hover}; }} + #helpLog {{ background: {p.sunken}; border: none; color: {p.text}; + padding: 4px 6px; }} + #helpInputRow {{ background: {p.surface}; + border-bottom-left-radius: {rl}px; border-bottom-right-radius: {rl}px; }} + #helpInput {{ border: 1px solid {p.border}; border-radius: {r}px; padding: 5px 8px; + background: {p.surface_raised}; color: {p.text}; }} + #helpInput:focus {{ border: 1px solid {p.focus_ring}; }} + #helpSendBtn {{ background: {p.accent_solid}; border: none; border-radius: {r}px; }} + #helpSendBtn:hover {{ background: {p.accent_solid_hover}; }} + #helpSendBtn:disabled {{ background: {p.border_strong}; }} """) # ---- greeting / labels ------------------------------------------------ @@ -177,7 +168,7 @@ class HelpAgentWidget(QWidget): # assistant back (chevron points left = "slide out"). self.edge_tab = QPushButton(self) self.edge_tab.setObjectName("helpEdgeTab") - self.edge_tab.setIcon(icon("chevron-left", color="#5a6570")) + self.edge_tab.setIcon(icon("chevron-left", color=self._pal.text_muted)) self.edge_tab.setCursor(Qt.PointingHandCursor) self.edge_tab.setToolTip(tr("help_agent.show_tooltip")) self.edge_tab.clicked.connect(self._show_launcher) @@ -186,7 +177,7 @@ class HelpAgentWidget(QWidget): # A left-side chevron collapses the assistant to the edge… self.collapse_btn = QPushButton(self) self.collapse_btn.setObjectName("helpCollapseBtn") - self.collapse_btn.setIcon(icon("chevron-right", color="#5a6570")) + self.collapse_btn.setIcon(icon("chevron-right", color=self._pal.text_muted)) self.collapse_btn.setCursor(Qt.PointingHandCursor) self.collapse_btn.setToolTip(tr("help_agent.hide_tooltip")) self.collapse_btn.clicked.connect(self._hide_to_edge) @@ -222,7 +213,7 @@ class HelpAgentWidget(QWidget): hb.addWidget(self.title, 1) self.min_btn = QPushButton(header) self.min_btn.setObjectName("helpMinBtn") - self.min_btn.setIcon(icon("minus", color="#5a6570")) + self.min_btn.setIcon(icon("minus", color=self._pal.text_muted)) self.min_btn.setFixedSize(24, 24) self.min_btn.setCursor(Qt.PointingHandCursor) self.min_btn.setToolTip(tr("help_agent.collapse_tooltip")) @@ -318,17 +309,19 @@ class HelpAgentWidget(QWidget): p = self._pal text = (content or "").replace("&", "&").replace("<", "<").replace(">", ">") text = text.replace("\n", "
    ") + # bgcolor= is a solid-only HTML attribute, hence accent_wash (pre-blended) + # rather than the translucent accent_soft used in style sheets. if who == "user": - align, bg, label_color = "right", p["user_bg"], p["user_label"] + align, bg, label_color = "right", p.accent_wash, p.accent label = tr("chat.you") else: - align, bg, label_color = "left", p["bot_bg"], p["bot_label"] + align, bg, label_color = "left", p.surface_raised, p.success label = tr("help_agent.title") return ( f'' f'
    ' f'' - f'
    ' + f'' f'{label}
    {text}' f'
    ' '
     
    ' # gap between turns diff --git a/ui/icons.py b/ui/icons.py index 1307367..a91160c 100644 --- a/ui/icons.py +++ b/ui/icons.py @@ -21,7 +21,12 @@ from PySide6.QtGui import QBrush, QColor, QIcon, QPainter, QPen, QPixmap from PySide6.QtSvg import QSvgRenderer from PySide6.QtWidgets import QComboBox, QHBoxLayout, QLabel, QWidget -_COLOR = "#8b8d98" # neutral grey, visible on both light and dark buttons +def _default_color() -> str: + """The default icon tint: the theme's muted text colour, so glyphs sit at + the same weight as the labels beside them. Resolved per call — icons are + painted bitmaps, so a theme switch must repaint them, not restyle them.""" + from ..theme import current_palette + return current_palette().text_muted def _hidpi_pixmap(size: int) -> QPixmap: @@ -230,10 +235,11 @@ def icon_picker_combo(current: str = "") -> QComboBox: return combo -def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon: +def icon(name: str, size: int = 16, color: str | None = None) -> QIcon: """A flat thin-line icon for ``name`` (see ``_PATHS`` for the full list), tinted ``color`` — rendered from local SVG data, no image files/network. Stroke width 1.7 matches the Nova Platform web app's shared icon set.""" + color = color or _default_color() # A user-added custom icon (full SVG under ~/.cowork_local/icons) is rendered # as-is (keeps its own colours). Then built-in glyphs; then a neutral fallback. if name not in _PATHS: @@ -264,9 +270,10 @@ def icon(name: str, size: int = 16, color: str = _COLOR) -> QIcon: return QIcon(pm) -def _panel_icon(fill_left: bool, size: int = 16, color: str = _COLOR) -> QIcon: +def _panel_icon(fill_left: bool, size: int = 16, color: str | None = None) -> QIcon: """A rounded panel split by a divider, with one narrow side filled solid (the 'sidebar' toggle look).""" + color = color or _default_color() pm = _hidpi_pixmap(size) p = QPainter(pm) p.setRenderHint(QPainter.Antialiasing) @@ -302,19 +309,24 @@ def collapse_right_icon() -> QIcon: return _panel_icon(fill_left=False) -def pixmap(name: str, size: int = 16, color: str = _COLOR) -> QPixmap: +def pixmap(name: str, size: int = 16, color: str | None = None) -> QPixmap: """The line-icon ``name`` as a QPixmap (for QLabel.setPixmap — QLabel has no setIcon). Same glyph/renderer as ``icon()``.""" return icon(name, size, color).pixmap(size, size) -# Status-LED colors — a filled dot, the one place a solid glyph (not a line +# Status-LED colours — a filled dot, the one place a solid glyph (not a line # icon) is the right metaphor for an on/off/running indicator. -DOT_GREEN = "#22c55e" -DOT_RED = "#ef4444" -DOT_AMBER = "#f59e0b" -DOT_BLUE = "#3b82f6" -DOT_GREY = "#9ca3af" +# +# Deliberately the SAME in light and dark. An LED means one thing regardless of +# theme, and these mid-saturation hues clear 3:1 against both #0B0B0C and +# #FFFFFF, so a status dot never has to be re-learned. Everything else in the +# UI goes through theme.palette(); this is the documented exception. +DOT_GREEN = "#2EA043" +DOT_RED = "#E5484D" +DOT_AMBER = "#B7791F" +DOT_BLUE = "#4C7BE8" +DOT_GREY = "#8B8B94" def dot_icon(color: str = DOT_GREY, size: int = 12) -> QIcon: @@ -338,7 +350,7 @@ class IconLabel(QWidget): status labels (lock/unlock, …) keep working.""" def __init__(self, name: str, text: str = "", *, size: int = 16, - color: str = _COLOR, gap: int = 6, parent=None): + color: str | None = None, gap: int = 6, parent=None): super().__init__(parent) self._size = size lay = QHBoxLayout(self) @@ -357,7 +369,7 @@ class IconLabel(QWidget): def setText(self, text: str) -> None: # noqa: N802 — QLabel-compatible alias self._text.setText(text) - def set_icon(self, name: str, color: str = _COLOR) -> None: + def set_icon(self, name: str, color: str | None = None) -> None: self._icon.setPixmap(pixmap(name, self._size, color)) def text_label(self) -> QLabel: diff --git a/ui/monitoring_tab.py b/ui/monitoring_tab.py index 699ce12..fa96f7a 100644 --- a/ui/monitoring_tab.py +++ b/ui/monitoring_tab.py @@ -36,6 +36,7 @@ from ..core import agent_roles, audit_log from ..core import usage_tracker as ut from ..i18n import on_language_changed, tr from ..state import AppContext +from ..theme import current_palette from .icons import icon, DOT_GREEN, DOT_RED, DOT_AMBER from .widgets import BudgetCard, StatCard, fmt_tokens @@ -801,7 +802,8 @@ class MonitoringTab(QWidget): mark = f"✗" name = event.get("name", "") or event.get("kind", "") rel = _relative_time(event.get("ts", "")) - suffix = f" — {rel}" if rel else "" + muted = current_palette().text_muted + suffix = f" — {rel}" if rel else "" return f"{mark} {name}{suffix}" def _refresh_usage_cards(self) -> None: diff --git a/ui/schedule_task_tab.py b/ui/schedule_task_tab.py index 7849ad7..cdd7f38 100644 --- a/ui/schedule_task_tab.py +++ b/ui/schedule_task_tab.py @@ -1,716 +1,719 @@ -"""Schedule Task tab — Kanban board for scheduled/automated tasks. - -Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed / -Paused. Cards drag between columns (dropping = changing status), double-click -edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View -logs / Create-next-from-output. Header has search, a type filter, Add Task -and AI Create Task (preview first — nothing is created until confirmed). -""" -from __future__ import annotations - -import copy -from pathlib import Path -from typing import Dict, List, Optional - -from PySide6.QtCore import Qt, Signal -from PySide6.QtWidgets import ( - QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, - QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, - QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget, - QTableWidgetItem, QVBoxLayout, QWidget, -) - -from ..core import tasks as taskrepo -from ..core.projects import list_projects -from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from .calendar_view import CalendarView -from .icons import icon -from .osutil import open_path - -_VIEWS = ("kanban", "calendar") - -# Priority shown as a plain text tag (no colored-emoji squares). Only the -# elevated priorities get a visible marker; low/medium stay unmarked as before. -_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"} - - -class _KanbanColumn(QListWidget): - """One status lane. Accepts drops from sibling columns; a drop means - 'move this task to my status'.""" - - task_dropped = Signal(str, str) # task_id, new_status - - def __init__(self, status: str): - super().__init__() - self.status = status - self.setDragDropMode(QAbstractItemView.DragDrop) - self.setDefaultDropAction(Qt.MoveAction) - # Shift/Ctrl-click several cards in the SAME column, then right-click - # → "Delete N selected" to bulk-remove tasks instead of one at a time. - self.setSelectionMode(QAbstractItemView.ExtendedSelection) - self.setWordWrap(True) - self.setMinimumWidth(190) - - def dropEvent(self, event): # noqa: N802 - source = event.source() - if isinstance(source, _KanbanColumn) and source is not self: - item = source.currentItem() - tid = item.data(Qt.UserRole) if item else None - if tid: - event.acceptProposedAction() - self.task_dropped.emit(tid, self.status) - return - event.ignore() - - -class ScheduleTaskTab(QWidget): - status_message = Signal(str) - - def __init__(self, ctx: AppContext, scheduler=None): - super().__init__() - self.ctx = ctx - self.scheduler = scheduler # TaskScheduler (may be None in tests) - self._ai_worker: Optional[AgentWorker] = None - self._tasks_dir: Optional[Path] = None # None → default repo dir - - root = QVBoxLayout(self) - - # ---- header ---------------------------------------------------- - header = QHBoxLayout() - self._title = QLabel() - self._title.setStyleSheet("font-weight:700; font-size:15px;") - self.counts_lbl = QLabel("") - self.counts_lbl.setObjectName("hint") - self.add_btn = QPushButton() - self.add_btn.setIcon(icon("plus")) - self.add_btn.setObjectName("primary") - self.add_btn.clicked.connect(self._add_task) - self.ai_btn = QPushButton() - self.ai_btn.setIcon(icon("sparkle")) - self.ai_btn.clicked.connect(self._ai_create) - self.view_combo = QComboBox() - for v in _VIEWS: - self.view_combo.addItem("", v) - self.view_combo.currentIndexChanged.connect(self._on_view_changed) - header.addWidget(self._title) - header.addWidget(self.counts_lbl, 1) - header.addWidget(self.view_combo) - header.addWidget(self.add_btn) - header.addWidget(self.ai_btn) - root.addLayout(header) - - # ---- board / calendar (two views of the SAME tasks) ----------------- - self._view_stack = QStackedWidget() - scroll = QScrollArea() - scroll.setWidgetResizable(True) - board = QWidget() - scroll.setWidget(board) - cols = QHBoxLayout(board) - cols.setSpacing(8) - self.columns: Dict[str, _KanbanColumn] = {} - self.column_headers: Dict[str, QLabel] = {} - for status in STATUSES: - box = QVBoxLayout() - head = QLabel() - head.setStyleSheet("font-weight:600;") - col = _KanbanColumn(status) - col.task_dropped.connect(self._on_task_dropped) - col.itemDoubleClicked.connect(self._on_double_click) - col.setContextMenuPolicy(Qt.CustomContextMenu) - col.customContextMenuRequested.connect( - lambda pos, c=col: self._context_menu(c, pos)) - box.addWidget(head) - box.addWidget(col, 1) - holder = QWidget() - holder.setLayout(box) - cols.addWidget(holder) - self.columns[status] = col - self.column_headers[status] = head - self._view_stack.addWidget(scroll) - self.calendar = CalendarView() - self.calendar.edit_task.connect(self._edit_task) - self.calendar.add_task_on_date.connect(self._add_task_on_date) - self._view_stack.addWidget(self.calendar) - root.addWidget(self._view_stack, 1) - - if self.scheduler is not None: - self.scheduler.tasks_changed.connect(self.refresh) - self.scheduler.task_started.connect(lambda _tid: self.refresh()) - self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh()) - - # Belt-and-braces: also re-read the board every 10s so a card's lane - # ALWAYS reflects reality (Scheduled → Running → Done) even if some - # change slipped past the signals (e.g. task files edited externally). - from PySide6.QtCore import QTimer - self._refresh_timer = QTimer(self) - self._refresh_timer.setInterval(10_000) - self._refresh_timer.timeout.connect(self.refresh) - self._refresh_timer.start() - - self.refresh() - on_language_changed(self._retranslate) - - # ---- i18n ------------------------------------------------------------ - def _retranslate(self) -> None: - self._title.setText(tr("schedtask.title")) - self.add_btn.setText(tr("schedtask.add_btn")) - self.add_btn.setToolTip(tr("schedtask.add_tooltip")) - self.ai_btn.setText(tr("schedtask.ai_btn")) - self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) - for i, v in enumerate(_VIEWS): - self.view_combo.setItemText(i, tr(f"schedtask.view.{v}")) - for status, col in self.columns.items(): - col.setToolTip(tr(f"schedtask.col_tip.{status}")) - self.refresh() - - # ---- Kanban / Calendar view switch -------------------------------- - def _on_view_changed(self) -> None: - self._view_stack.setCurrentIndex(self.view_combo.currentIndex()) - - def _add_task_on_date(self, date_str: str) -> None: - """Create a task pre-filled with the clicked calendar date (default - 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" - from .task_editor_dialog import TaskEditorDialog - - t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) - dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - - # ---- board rendering --------------------------------------------------- - def _card_text(self, t: dict) -> str: - prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "") - ai = "[AI] " if t.get("is_ai_generated") else "" - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else None - when_line = when or tr("schedtask.no_schedule") - chain = "" - if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"): - chain = " (linked)" - last = t.get("logs", {}).get("last_status") - last_line = {"success": tr("schedtask.last_success"), - "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never")) - # Card shows ONLY the task's own title (plus the [AI] marker and chain - # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it. - return (f"{ai}{t.get('title', '')}{chain}\n" - f"{when_line} {prio}\n{last_line}") - - def refresh(self) -> None: - all_tasks = taskrepo.list_tasks(self._tasks_dir) - counts = {s: 0 for s in STATUSES} - for col in self.columns.values(): - col.clear() - for t in all_tasks: - status = t.get("status", "backlog") - if status not in self.columns: - continue - counts[status] += 1 - item = QListWidgetItem(self._card_text(t)) - item.setData(Qt.UserRole, t["task_id"]) - self.columns[status].addItem(item) - for status, col in self.columns.items(): - self.column_headers[status].setText( - f"{tr(f'schedtask.status.{status}')} ({counts[status]})") - if col.count() == 0: - empty = QListWidgetItem(tr("schedtask.no_tasks")) - empty.setFlags(Qt.NoItemFlags) - col.addItem(empty) - self.counts_lbl.setText(" ".join( - f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])) - self.calendar.set_tasks(all_tasks) - - # ---- actions -------------------------------------------------------- - def _save_and_refresh(self, task: dict) -> None: - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - - def _add_task(self) -> None: - from .task_editor_dialog import TaskEditorDialog - - dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - self.status_message.emit(tr("schedtask.msg_created")) - - def _edit_task(self, task_id: str) -> None: - from .task_editor_dialog import TaskEditorDialog - - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) - if dlg.exec() and dlg.edited_task: - self._save_and_refresh(dlg.edited_task) - - def _on_double_click(self, item: QListWidgetItem) -> None: - tid = item.data(Qt.UserRole) - if tid: - self._edit_task(tid) - - def _on_task_dropped(self, task_id: str, new_status: str) -> None: - """Dropping a card into a lane ACTS on the task, not just relabels it: - → Running actually runs it now; → Done marks it completed; → Scheduled - puts it on the calendar (opening the editor if no time is set yet).""" - task = taskrepo.load_task(task_id, self._tasks_dir) - if not task: - return - if task.get("status") == "running": - self.refresh() # can't drag a running task - return - if new_status == "running": - # Dropping into Running = "run it now" (counts as manual approval). - self.refresh() - self._run_now(task) - return - if new_status == "done": - task["status"] = "done" - task["schedule"]["enabled"] = False # done by hand → don't re-fire - self._save_and_refresh(task) - return - task["status"] = new_status - if new_status == "scheduled" and not task["schedule"].get("enabled"): - if task["schedule"].get("run_at"): - task["schedule"]["enabled"] = True - else: - # No time set yet — a silently-disabled "Scheduled" card would - # never run and look broken. Open the editor so the user sets - # the schedule right away. - self._save_and_refresh(task) - self.status_message.emit(tr("schedtask.msg_set_schedule")) - self._edit_task(task_id) - return - self._save_and_refresh(task) - - @staticmethod - def _is_multi_selection(item, selected) -> bool: - """True when the right-clicked card is part of an existing multi-item - selection — pure boolean, kept separate from _context_menu so it's - testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" - return len(selected) > 1 and item in selected - - def _context_menu(self, col: _KanbanColumn, pos) -> None: - item = col.itemAt(pos) - if item is None or not item.data(Qt.UserRole): - return - selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] - if self._is_multi_selection(item, selected): - self._bulk_delete_menu(col, pos, selected) - return - tid = item.data(Qt.UserRole) - task = taskrepo.load_task(tid, self._tasks_dir) - if not task: - return - menu = QMenu(col) - run_act = menu.addAction(tr("schedtask.menu_run")) - edit_act = menu.addAction(tr("schedtask.menu_edit")) - dup_act = menu.addAction(tr("schedtask.menu_duplicate")) - paused = task.get("status") == "paused" - pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) - logs_act = menu.addAction(tr("schedtask.menu_logs")) - hist_act = menu.addAction(tr("schedtask.menu_history")) - next_act = menu.addAction(tr("schedtask.menu_create_next")) - menu.addSeparator() - del_act = menu.addAction(tr("schedtask.menu_delete")) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == run_act: - self._run_now(task) - elif chosen == edit_act: - self._edit_task(tid) - elif chosen == dup_act: - self._save_and_refresh(duplicate_task(task)) - elif chosen == pause_act: - task["status"] = "backlog" if paused else "paused" - self._save_and_refresh(task) - elif chosen == logs_act: - self._view_logs(task) - elif chosen == hist_act: - _RunHistoryDialog(task, self).exec() - elif chosen == next_act: - self._create_next_from_output(task) - elif chosen == del_act: - if QMessageBox.question(self, tr("schedtask.menu_delete"), - tr("schedtask.delete_confirm", title=task.get("title", "")) - ) == QMessageBox.Yes: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - - def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: - """Right-click on a multi-selection within one column (Shift/Ctrl-click - several cards first): one action deletes every selected task. The - popup itself is a thin wrapper — see _confirm_and_delete_selected for - the actual (independently testable) confirm+delete logic.""" - menu = QMenu(col) - del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) - chosen = menu.exec(col.viewport().mapToGlobal(pos)) - if chosen == del_act: - self._confirm_and_delete_selected(selected) - - def _confirm_and_delete_selected(self, selected) -> bool: - """Confirm, then delete every task in ``selected``. Split out of - _bulk_delete_menu so tests can drive it directly without having to - fake a real (modal, event-loop-blocking) QMenu popup.""" - if QMessageBox.question( - self, tr("schedtask.menu_delete"), - tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: - return False - for item in selected: - tid = item.data(Qt.UserRole) - if tid: - taskrepo.delete_task(tid, self._tasks_dir) - self.refresh() - return True - - def _run_now(self, task: dict) -> None: - if task.get("task_type") == "manual": - self.status_message.emit(tr("schedtask.msg_manual_norun")) - return - if self.scheduler is None: - self.status_message.emit(tr("schedtask.msg_no_scheduler")) - return - if self.scheduler.run_now(task["task_id"]): - self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) - self.refresh() - - def _view_logs(self, task: dict) -> None: - run_id = task.get("logs", {}).get("last_run_id") - if not run_id: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - return - folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - else: - QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) - - def _create_next_from_output(self, task: dict) -> None: - """Scaffold a follow-up task pre-wired to consume this task's output.""" - nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) - nxt["task_type"] = "cowork" - nxt["input"]["mode"] = "previous_task_output" - nxt["input"]["previous_task_id"] = task["task_id"] - nxt["dependency"]["previous_task_id"] = task["task_id"] - err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], - task["task_id"], nxt["task_id"]) - if err: - QMessageBox.warning(self, tr("schedtask.g_dependency"), err) - return - taskrepo.save_task(nxt, self._tasks_dir) - task["dependency"]["next_task_id"] = nxt["task_id"] - task["dependency"]["pass_output_to_next"] = True - if task["dependency"].get("run_next_mode", "none") == "none": - task["dependency"]["run_next_mode"] = "run_after_success" - taskrepo.save_task(task, self._tasks_dir) - self.refresh() - self._edit_task(nxt["task_id"]) - - # ---- AI create ---------------------------------------------------------- - def _ai_create(self) -> None: - dlg = _AiCreateDialog(self.ctx, self) - if dlg.exec() and dlg.created_tasks: - for t in dlg.created_tasks: - taskrepo.save_task(t, self._tasks_dir) - self.refresh() - self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) - - -class _RunHistoryDialog(QDialog): - """Run history of one task as a table (newest first): time, status, error; - double-click a row to open that run's artifact folder.""" - - def __init__(self, task: dict, parent=None): - super().__init__(parent) - self._task = task - self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}") - self.resize(620, 380) - root = QVBoxLayout(self) - hint = QLabel(tr("schedtask.hist_hint")) - hint.setObjectName("hint") - root.addWidget(hint) - - runs = list(reversed(task.get("runs", []) or [])) - self.table = QTableWidget(len(runs), 4) - self.table.setHorizontalHeaderLabels([ - tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"), - tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"), - ]) - self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) - self.table.setSelectionBehavior(QAbstractItemView.SelectRows) - for row, run in enumerate(runs): - ok = run.get("status") == "success" - cells = ( - run.get("finished_at", ""), - str(run.get("status", "")), - run.get("run_id", ""), - (run.get("error") or "")[:200], - ) - for col, text in enumerate(cells): - item = QTableWidgetItem(str(text)) - if col == 0: - item.setData(Qt.UserRole, run.get("run_id", "")) - self.table.setItem(row, col, item) - self.table.resizeColumnsToContents() - self.table.horizontalHeader().setStretchLastSection(True) - self.table.itemDoubleClicked.connect(self._open_artifact) - root.addWidget(self.table, 1) - - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(self.reject) - buttons.accepted.connect(self.accept) - root.addWidget(buttons) - - def _open_artifact(self, item: QTableWidgetItem) -> None: - first = self.table.item(item.row(), 0) - run_id = first.data(Qt.UserRole) if first else "" - if not run_id: - return - folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id - if folder.exists(): - open_path(str(folder)) - - -class _DropZone(QLabel): - """Drag-an-.xlsx-here area for the Import tab.""" - - file_dropped = Signal(str) - - def __init__(self): - super().__init__() - self.setAlignment(Qt.AlignCenter) - self.setMinimumHeight(70) - self.setStyleSheet( - "QLabel { border: 2px dashed rgba(140,146,152,0.6); border-radius: 10px;" - " color: #8c9298; padding: 10px; }") - self.setAcceptDrops(True) - - def dragEnterEvent(self, event): # noqa: N802 - urls = event.mimeData().urls() - if urls and urls[0].toLocalFile().lower().endswith( - (".xlsx", ".xlsm", ".xls", ".csv", ".json")): - event.acceptProposedAction() - - def dropEvent(self, event): # noqa: N802 - urls = event.mimeData().urls() - if urls: - self.file_dropped.emit(urls[0].toLocalFile()) - - -class _AiCreateDialog(QDialog): - """Create tasks two ways, one tab each (both preview first — nothing is - saved until the user confirms): ✨ AI gen from a natural-language - description, or 📥 Import from a filled Excel template (pick or drag).""" - - def __init__(self, ctx: AppContext, parent=None): - super().__init__(parent) - from PySide6.QtWidgets import QTabWidget - - self.ctx = ctx - self.created_tasks: List[dict] = [] - self._planned: List[dict] = [] - self._worker: Optional[AgentWorker] = None - self.setWindowTitle(tr("schedtask.ai_btn")) - self.resize(600, 520) - - root = QVBoxLayout(self) - ws_row = QHBoxLayout() - ws_row.addWidget(QLabel(tr("schedtask.f_workspace"))) - self.workspace_combo = QComboBox() - self.workspace_combo.addItem(tr("schedtask.no_workspace"), "") - for p in list_projects(): - self.workspace_combo.addItem(p.name, p.project_id) - self.workspace_combo.setToolTip(tr("schedtask.hint_workspace")) - ws_row.addWidget(self.workspace_combo, 1) - root.addLayout(ws_row) - self.tabs = QTabWidget() - root.addWidget(self.tabs, 1) - - # ---- tab 1: AI gen ------------------------------------------------ - ai_page = QWidget() - al = QVBoxLayout(ai_page) - al.addWidget(QLabel(tr("schedtask.ai_desc_label"))) - self.desc_edit = QPlainTextEdit() - self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph")) - self.desc_edit.setMaximumHeight(110) - al.addWidget(self.desc_edit) - # Attachments (files + links) — merged into every task this generates, - # AND into the planning prompt so the AI knows they exist. - attach_row = QHBoxLayout() - self.ai_files_edit = QLineEdit() - self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder")) - ai_pick_btn = QPushButton(tr("schedtask.pick_files")) - ai_pick_btn.setIcon(icon("folder")) - ai_pick_btn.clicked.connect(self._ai_pick_files) - attach_row.addWidget(self.ai_files_edit, 1) - attach_row.addWidget(ai_pick_btn) - al.addWidget(QLabel(tr("schedtask.f_files"))) - al.addLayout(attach_row) - self.ai_links_edit = QLineEdit() - self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder")) - al.addWidget(QLabel(tr("schedtask.f_links"))) - al.addWidget(self.ai_links_edit) - self.gen_btn = QPushButton(tr("schedtask.ai_generate")) - self.gen_btn.setIcon(icon("sparkle")) - self.gen_btn.setObjectName("primary") - self.gen_btn.clicked.connect(self._generate) - al.addWidget(self.gen_btn) - al.addWidget(QLabel(tr("schedtask.ai_preview_label"))) - self.preview = QPlainTextEdit() - self.preview.setReadOnly(True) - al.addWidget(self.preview, 1) - self.tabs.addTab(ai_page, tr("schedtask.tab_ai")) - - # ---- tab 2: Import from Excel -------------------------------------- - imp_page = QWidget() - il = QVBoxLayout(imp_page) - tpl_btn = QPushButton(tr("schedtask.export_template_btn")) - tpl_btn.setIcon(icon("upload")) - tpl_btn.clicked.connect(self._export_template) - il.addWidget(tpl_btn) - pick_row = QHBoxLayout() - pick_btn = QPushButton(tr("schedtask.import_pick_btn")) - pick_btn.setIcon(icon("folder")) - pick_btn.clicked.connect(self._pick_import_file) - pick_row.addWidget(pick_btn) - pick_row.addStretch(1) - il.addLayout(pick_row) - self.drop_zone = _DropZone() - self.drop_zone.setText(tr("schedtask.drop_hint")) - self.drop_zone.file_dropped.connect(self._load_import_file) - il.addWidget(self.drop_zone) - il.addWidget(QLabel(tr("schedtask.ai_preview_label"))) - self.import_preview = QPlainTextEdit() - self.import_preview.setReadOnly(True) - il.addWidget(self.import_preview, 1) - self.tabs.addTab(imp_page, tr("schedtask.tab_import")) - - self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) - self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) - self.buttons.accepted.connect(self._confirm) - self.buttons.rejected.connect(self.reject) - root.addWidget(self.buttons) - - # ---- Import tab ------------------------------------------------------ - def _export_template(self) -> None: - from PySide6.QtWidgets import QFileDialog - - from ..core.task_excel import export_template - - path, _ = QFileDialog.getSaveFileName( - self, tr("schedtask.export_template_btn"), - "cowork_tasks_template.xlsx", "Excel (*.xlsx)") - if not path: - return - try: - export_template(path) - open_path(str(Path(path).parent)) - except Exception as exc: # noqa: BLE001 - QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc)) - - def _pick_import_file(self) -> None: - from PySide6.QtWidgets import QFileDialog - - from ..core.task_import import IMPORT_FILTER - - path, _ = QFileDialog.getOpenFileName( - self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER) - if path: - self._load_import_file(path) - - def _load_import_file(self, path: str) -> None: - from ..core.task_import import import_tasks - - try: - self._planned = import_tasks(path) - except ValueError as exc: - self.import_preview.setPlainText(str(exc)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) - return - by_id = {t["task_id"]: t["title"] for t in self._planned} - lines = [] - for i, t in enumerate(self._planned, 1): - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") - deps = t.get("dependency", {}).get("depends_on") or [] - dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else "" - lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" - f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}") - self.import_preview.setPlainText("\n\n".join(lines)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) - - def _ai_pick_files(self) -> None: - from PySide6.QtWidgets import QFileDialog - - files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files")) - if files: - existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()] - self.ai_files_edit.setText("; ".join(existing + files)) - - def _attached_files(self) -> List[str]: - return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()] - - def _attached_links(self) -> List[str]: - return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()] - - def _generate(self) -> None: - description = self.desc_edit.toPlainText().strip() - if not description or self._worker is not None: - return - files, links = self._attached_files(), self._attached_links() - self.gen_btn.setEnabled(False) - self.gen_btn.setText(tr("schedtask.ai_generating")) - - def job(worker: AgentWorker): - from ..core.ai_task_planner import plan_tasks - - provider = self.ctx.build_active_provider() - full_desc = description - if files or links: - attach_note = "; ".join(files + links) - full_desc += f"\n\n(Attached references available: {attach_note})" - planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled) - # Attachments apply to every generated task so they're available - # at RUN time too, not just visible to the planner. - for t in planned: - t["input"]["file_paths"] = list(files) - t["input"]["links"] = list(links) - return {"tasks": planned} - - w = AgentWorker(job) - w.finished_ok.connect(self._on_planned) - w.failed.connect(self._on_failed) - self._worker = w - w.start() - - def _on_planned(self, result: dict) -> None: - self._worker = None - self.gen_btn.setEnabled(True) - self.gen_btn.setText(tr("schedtask.ai_generate")) - self._planned = result.get("tasks") or [] - lines = [] - for i, t in enumerate(self._planned, 1): - sched = t.get("schedule", {}) - when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") - dep = t.get("dependency", {}) - chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else "" - lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" - f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n" - f" {t.get('description', '')[:150]}") - self.preview.setPlainText("\n\n".join(lines)) - self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) - - def _on_failed(self, err: str) -> None: - self._worker = None - self.gen_btn.setEnabled(True) - self.gen_btn.setText(tr("schedtask.ai_generate")) - self.preview.setPlainText(str(err)) - - def _confirm(self) -> None: - project_id = self.workspace_combo.currentData() or "" - for t in self._planned: - t["project_id"] = project_id - self.created_tasks = self._planned - self.accept() +"""Schedule Task tab — Kanban board for scheduled/automated tasks. + +Columns: Backlog / Scheduled / Running / Waiting Input / Done / Failed / +Paused. Cards drag between columns (dropping = changing status), double-click +edits, right-click offers Run now / Edit / Duplicate / Pause / Delete / View +logs / Create-next-from-output. Header has search, a type filter, Add Task +and AI Create Task (preview first — nothing is created until confirmed). +""" +from __future__ import annotations + +import copy +from pathlib import Path +from typing import Dict, List, Optional + +from PySide6.QtCore import Qt, Signal +from PySide6.QtWidgets import ( + QAbstractItemView, QComboBox, QDialog, QDialogButtonBox, QHBoxLayout, + QLabel, QLineEdit, QListWidget, QListWidgetItem, QMenu, QMessageBox, + QPlainTextEdit, QPushButton, QScrollArea, QStackedWidget, QTableWidget, + QTableWidgetItem, QVBoxLayout, QWidget, +) + +from ..core import tasks as taskrepo +from ..core.projects import list_projects +from ..core.tasks import STATUSES, chain_error, duplicate_task, new_task +from ..core.worker import AgentWorker +from ..i18n import on_language_changed, tr +from ..state import AppContext +from ..theme import current_palette +from .calendar_view import CalendarView +from .icons import icon +from .osutil import open_path + +_VIEWS = ("kanban", "calendar") + +# Priority shown as a plain text tag (no colored-emoji squares). Only the +# elevated priorities get a visible marker; low/medium stay unmarked as before. +_PRIORITY_ICONS = {"low": "", "medium": "", "high": "· high", "critical": "· critical"} + + +class _KanbanColumn(QListWidget): + """One status lane. Accepts drops from sibling columns; a drop means + 'move this task to my status'.""" + + task_dropped = Signal(str, str) # task_id, new_status + + def __init__(self, status: str): + super().__init__() + self.status = status + self.setDragDropMode(QAbstractItemView.DragDrop) + self.setDefaultDropAction(Qt.MoveAction) + # Shift/Ctrl-click several cards in the SAME column, then right-click + # → "Delete N selected" to bulk-remove tasks instead of one at a time. + self.setSelectionMode(QAbstractItemView.ExtendedSelection) + self.setWordWrap(True) + self.setMinimumWidth(190) + + def dropEvent(self, event): # noqa: N802 + source = event.source() + if isinstance(source, _KanbanColumn) and source is not self: + item = source.currentItem() + tid = item.data(Qt.UserRole) if item else None + if tid: + event.acceptProposedAction() + self.task_dropped.emit(tid, self.status) + return + event.ignore() + + +class ScheduleTaskTab(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext, scheduler=None): + super().__init__() + self.ctx = ctx + self.scheduler = scheduler # TaskScheduler (may be None in tests) + self._ai_worker: Optional[AgentWorker] = None + self._tasks_dir: Optional[Path] = None # None → default repo dir + + root = QVBoxLayout(self) + + # ---- header ---------------------------------------------------- + header = QHBoxLayout() + self._title = QLabel() + self._title.setStyleSheet("font-weight:700; font-size:15px;") + self.counts_lbl = QLabel("") + self.counts_lbl.setObjectName("hint") + self.add_btn = QPushButton() + self.add_btn.setIcon(icon("plus")) + self.add_btn.setObjectName("primary") + self.add_btn.clicked.connect(self._add_task) + self.ai_btn = QPushButton() + self.ai_btn.setIcon(icon("sparkle")) + self.ai_btn.clicked.connect(self._ai_create) + self.view_combo = QComboBox() + for v in _VIEWS: + self.view_combo.addItem("", v) + self.view_combo.currentIndexChanged.connect(self._on_view_changed) + header.addWidget(self._title) + header.addWidget(self.counts_lbl, 1) + header.addWidget(self.view_combo) + header.addWidget(self.add_btn) + header.addWidget(self.ai_btn) + root.addLayout(header) + + # ---- board / calendar (two views of the SAME tasks) ----------------- + self._view_stack = QStackedWidget() + scroll = QScrollArea() + scroll.setWidgetResizable(True) + board = QWidget() + scroll.setWidget(board) + cols = QHBoxLayout(board) + cols.setSpacing(8) + self.columns: Dict[str, _KanbanColumn] = {} + self.column_headers: Dict[str, QLabel] = {} + for status in STATUSES: + box = QVBoxLayout() + head = QLabel() + head.setStyleSheet("font-weight:600;") + col = _KanbanColumn(status) + col.task_dropped.connect(self._on_task_dropped) + col.itemDoubleClicked.connect(self._on_double_click) + col.setContextMenuPolicy(Qt.CustomContextMenu) + col.customContextMenuRequested.connect( + lambda pos, c=col: self._context_menu(c, pos)) + box.addWidget(head) + box.addWidget(col, 1) + holder = QWidget() + holder.setLayout(box) + cols.addWidget(holder) + self.columns[status] = col + self.column_headers[status] = head + self._view_stack.addWidget(scroll) + self.calendar = CalendarView() + self.calendar.edit_task.connect(self._edit_task) + self.calendar.add_task_on_date.connect(self._add_task_on_date) + self._view_stack.addWidget(self.calendar) + root.addWidget(self._view_stack, 1) + + if self.scheduler is not None: + self.scheduler.tasks_changed.connect(self.refresh) + self.scheduler.task_started.connect(lambda _tid: self.refresh()) + self.scheduler.task_finished.connect(lambda _tid, _ok: self.refresh()) + + # Belt-and-braces: also re-read the board every 10s so a card's lane + # ALWAYS reflects reality (Scheduled → Running → Done) even if some + # change slipped past the signals (e.g. task files edited externally). + from PySide6.QtCore import QTimer + self._refresh_timer = QTimer(self) + self._refresh_timer.setInterval(10_000) + self._refresh_timer.timeout.connect(self.refresh) + self._refresh_timer.start() + + self.refresh() + on_language_changed(self._retranslate) + + # ---- i18n ------------------------------------------------------------ + def _retranslate(self) -> None: + self._title.setText(tr("schedtask.title")) + self.add_btn.setText(tr("schedtask.add_btn")) + self.add_btn.setToolTip(tr("schedtask.add_tooltip")) + self.ai_btn.setText(tr("schedtask.ai_btn")) + self.ai_btn.setToolTip(tr("schedtask.ai_tooltip")) + for i, v in enumerate(_VIEWS): + self.view_combo.setItemText(i, tr(f"schedtask.view.{v}")) + for status, col in self.columns.items(): + col.setToolTip(tr(f"schedtask.col_tip.{status}")) + self.refresh() + + # ---- Kanban / Calendar view switch -------------------------------- + def _on_view_changed(self) -> None: + self._view_stack.setCurrentIndex(self.view_combo.currentIndex()) + + def _add_task_on_date(self, date_str: str) -> None: + """Create a task pre-filled with the clicked calendar date (default + 09:00) — same editor Add Task opens, nothing is saved until confirmed.""" + from .task_editor_dialog import TaskEditorDialog + + t = new_task("", schedule={"enabled": True, "run_at": f"{date_str} 09:00"}) + dlg = TaskEditorDialog(t, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + self.status_message.emit(tr("schedtask.msg_created")) + + # ---- board rendering --------------------------------------------------- + def _card_text(self, t: dict) -> str: + prio = _PRIORITY_ICONS.get(t.get("priority", "medium"), "") + ai = "[AI] " if t.get("is_ai_generated") else "" + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else None + when_line = when or tr("schedtask.no_schedule") + chain = "" + if t.get("dependency", {}).get("next_task_id") or t.get("dependency", {}).get("previous_task_id"): + chain = " (linked)" + last = t.get("logs", {}).get("last_status") + last_line = {"success": tr("schedtask.last_success"), + "failed": tr("schedtask.last_failed")}.get(last, tr("schedtask.last_never")) + # Card shows ONLY the task's own title (plus the [AI] marker and chain + # note) — no "[Cowork]"/"[Code]" task-type tag cluttering it. + return (f"{ai}{t.get('title', '')}{chain}\n" + f"{when_line} {prio}\n{last_line}") + + def refresh(self) -> None: + all_tasks = taskrepo.list_tasks(self._tasks_dir) + counts = {s: 0 for s in STATUSES} + for col in self.columns.values(): + col.clear() + for t in all_tasks: + status = t.get("status", "backlog") + if status not in self.columns: + continue + counts[status] += 1 + item = QListWidgetItem(self._card_text(t)) + item.setData(Qt.UserRole, t["task_id"]) + self.columns[status].addItem(item) + for status, col in self.columns.items(): + self.column_headers[status].setText( + f"{tr(f'schedtask.status.{status}')} ({counts[status]})") + if col.count() == 0: + empty = QListWidgetItem(tr("schedtask.no_tasks")) + empty.setFlags(Qt.NoItemFlags) + col.addItem(empty) + self.counts_lbl.setText(" ".join( + f"{tr(f'schedtask.status.{s}')}: {counts[s]}" for s in STATUSES if counts[s])) + self.calendar.set_tasks(all_tasks) + + # ---- actions -------------------------------------------------------- + def _save_and_refresh(self, task: dict) -> None: + taskrepo.save_task(task, self._tasks_dir) + self.refresh() + + def _add_task(self) -> None: + from .task_editor_dialog import TaskEditorDialog + + dlg = TaskEditorDialog(None, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + self.status_message.emit(tr("schedtask.msg_created")) + + def _edit_task(self, task_id: str) -> None: + from .task_editor_dialog import TaskEditorDialog + + task = taskrepo.load_task(task_id, self._tasks_dir) + if not task: + return + dlg = TaskEditorDialog(task, taskrepo.list_tasks(self._tasks_dir), self, ctx=self.ctx) + if dlg.exec() and dlg.edited_task: + self._save_and_refresh(dlg.edited_task) + + def _on_double_click(self, item: QListWidgetItem) -> None: + tid = item.data(Qt.UserRole) + if tid: + self._edit_task(tid) + + def _on_task_dropped(self, task_id: str, new_status: str) -> None: + """Dropping a card into a lane ACTS on the task, not just relabels it: + → Running actually runs it now; → Done marks it completed; → Scheduled + puts it on the calendar (opening the editor if no time is set yet).""" + task = taskrepo.load_task(task_id, self._tasks_dir) + if not task: + return + if task.get("status") == "running": + self.refresh() # can't drag a running task + return + if new_status == "running": + # Dropping into Running = "run it now" (counts as manual approval). + self.refresh() + self._run_now(task) + return + if new_status == "done": + task["status"] = "done" + task["schedule"]["enabled"] = False # done by hand → don't re-fire + self._save_and_refresh(task) + return + task["status"] = new_status + if new_status == "scheduled" and not task["schedule"].get("enabled"): + if task["schedule"].get("run_at"): + task["schedule"]["enabled"] = True + else: + # No time set yet — a silently-disabled "Scheduled" card would + # never run and look broken. Open the editor so the user sets + # the schedule right away. + self._save_and_refresh(task) + self.status_message.emit(tr("schedtask.msg_set_schedule")) + self._edit_task(task_id) + return + self._save_and_refresh(task) + + @staticmethod + def _is_multi_selection(item, selected) -> bool: + """True when the right-clicked card is part of an existing multi-item + selection — pure boolean, kept separate from _context_menu so it's + testable without ever invoking Qt's (modal, event-loop-blocking) menu.""" + return len(selected) > 1 and item in selected + + def _context_menu(self, col: _KanbanColumn, pos) -> None: + item = col.itemAt(pos) + if item is None or not item.data(Qt.UserRole): + return + selected = [it for it in col.selectedItems() if it.data(Qt.UserRole)] + if self._is_multi_selection(item, selected): + self._bulk_delete_menu(col, pos, selected) + return + tid = item.data(Qt.UserRole) + task = taskrepo.load_task(tid, self._tasks_dir) + if not task: + return + menu = QMenu(col) + run_act = menu.addAction(tr("schedtask.menu_run")) + edit_act = menu.addAction(tr("schedtask.menu_edit")) + dup_act = menu.addAction(tr("schedtask.menu_duplicate")) + paused = task.get("status") == "paused" + pause_act = menu.addAction(tr("schedtask.menu_resume" if paused else "schedtask.menu_pause")) + logs_act = menu.addAction(tr("schedtask.menu_logs")) + hist_act = menu.addAction(tr("schedtask.menu_history")) + next_act = menu.addAction(tr("schedtask.menu_create_next")) + menu.addSeparator() + del_act = menu.addAction(tr("schedtask.menu_delete")) + chosen = menu.exec(col.viewport().mapToGlobal(pos)) + if chosen == run_act: + self._run_now(task) + elif chosen == edit_act: + self._edit_task(tid) + elif chosen == dup_act: + self._save_and_refresh(duplicate_task(task)) + elif chosen == pause_act: + task["status"] = "backlog" if paused else "paused" + self._save_and_refresh(task) + elif chosen == logs_act: + self._view_logs(task) + elif chosen == hist_act: + _RunHistoryDialog(task, self).exec() + elif chosen == next_act: + self._create_next_from_output(task) + elif chosen == del_act: + if QMessageBox.question(self, tr("schedtask.menu_delete"), + tr("schedtask.delete_confirm", title=task.get("title", "")) + ) == QMessageBox.Yes: + taskrepo.delete_task(tid, self._tasks_dir) + self.refresh() + + def _bulk_delete_menu(self, col: _KanbanColumn, pos, selected) -> None: + """Right-click on a multi-selection within one column (Shift/Ctrl-click + several cards first): one action deletes every selected task. The + popup itself is a thin wrapper — see _confirm_and_delete_selected for + the actual (independently testable) confirm+delete logic.""" + menu = QMenu(col) + del_act = menu.addAction(tr("schedtask.menu_delete_selected", n=len(selected))) + chosen = menu.exec(col.viewport().mapToGlobal(pos)) + if chosen == del_act: + self._confirm_and_delete_selected(selected) + + def _confirm_and_delete_selected(self, selected) -> bool: + """Confirm, then delete every task in ``selected``. Split out of + _bulk_delete_menu so tests can drive it directly without having to + fake a real (modal, event-loop-blocking) QMenu popup.""" + if QMessageBox.question( + self, tr("schedtask.menu_delete"), + tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes: + return False + for item in selected: + tid = item.data(Qt.UserRole) + if tid: + taskrepo.delete_task(tid, self._tasks_dir) + self.refresh() + return True + + def _run_now(self, task: dict) -> None: + if task.get("task_type") == "manual": + self.status_message.emit(tr("schedtask.msg_manual_norun")) + return + if self.scheduler is None: + self.status_message.emit(tr("schedtask.msg_no_scheduler")) + return + if self.scheduler.run_now(task["task_id"]): + self.status_message.emit(tr("schedtask.msg_running", title=task.get("title", ""))) + self.refresh() + + def _view_logs(self, task: dict) -> None: + run_id = task.get("logs", {}).get("last_run_id") + if not run_id: + QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) + return + folder = taskrepo.ARTIFACTS_DIR / task["task_id"] / run_id + if folder.exists(): + open_path(str(folder)) + else: + QMessageBox.information(self, tr("schedtask.menu_logs"), tr("schedtask.no_runs_yet")) + + def _create_next_from_output(self, task: dict) -> None: + """Scaffold a follow-up task pre-wired to consume this task's output.""" + nxt = new_task(tr("schedtask.next_of", title=task.get("title", ""))) + nxt["task_type"] = "cowork" + nxt["input"]["mode"] = "previous_task_output" + nxt["input"]["previous_task_id"] = task["task_id"] + nxt["dependency"]["previous_task_id"] = task["task_id"] + err = chain_error(taskrepo.list_tasks(self._tasks_dir) + [nxt], + task["task_id"], nxt["task_id"]) + if err: + QMessageBox.warning(self, tr("schedtask.g_dependency"), err) + return + taskrepo.save_task(nxt, self._tasks_dir) + task["dependency"]["next_task_id"] = nxt["task_id"] + task["dependency"]["pass_output_to_next"] = True + if task["dependency"].get("run_next_mode", "none") == "none": + task["dependency"]["run_next_mode"] = "run_after_success" + taskrepo.save_task(task, self._tasks_dir) + self.refresh() + self._edit_task(nxt["task_id"]) + + # ---- AI create ---------------------------------------------------------- + def _ai_create(self) -> None: + dlg = _AiCreateDialog(self.ctx, self) + if dlg.exec() and dlg.created_tasks: + for t in dlg.created_tasks: + taskrepo.save_task(t, self._tasks_dir) + self.refresh() + self.status_message.emit(tr("schedtask.msg_ai_created", n=len(dlg.created_tasks))) + + +class _RunHistoryDialog(QDialog): + """Run history of one task as a table (newest first): time, status, error; + double-click a row to open that run's artifact folder.""" + + def __init__(self, task: dict, parent=None): + super().__init__(parent) + self._task = task + self.setWindowTitle(f"{tr('schedtask.menu_history')} — {task.get('title', '')}") + self.resize(620, 380) + root = QVBoxLayout(self) + hint = QLabel(tr("schedtask.hist_hint")) + hint.setObjectName("hint") + root.addWidget(hint) + + runs = list(reversed(task.get("runs", []) or [])) + self.table = QTableWidget(len(runs), 4) + self.table.setHorizontalHeaderLabels([ + tr("schedtask.hist_col_time"), tr("schedtask.hist_col_status"), + tr("schedtask.hist_col_run"), tr("schedtask.hist_col_error"), + ]) + self.table.setEditTriggers(QAbstractItemView.NoEditTriggers) + self.table.setSelectionBehavior(QAbstractItemView.SelectRows) + for row, run in enumerate(runs): + ok = run.get("status") == "success" + cells = ( + run.get("finished_at", ""), + str(run.get("status", "")), + run.get("run_id", ""), + (run.get("error") or "")[:200], + ) + for col, text in enumerate(cells): + item = QTableWidgetItem(str(text)) + if col == 0: + item.setData(Qt.UserRole, run.get("run_id", "")) + self.table.setItem(row, col, item) + self.table.resizeColumnsToContents() + self.table.horizontalHeader().setStretchLastSection(True) + self.table.itemDoubleClicked.connect(self._open_artifact) + root.addWidget(self.table, 1) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(self.reject) + buttons.accepted.connect(self.accept) + root.addWidget(buttons) + + def _open_artifact(self, item: QTableWidgetItem) -> None: + first = self.table.item(item.row(), 0) + run_id = first.data(Qt.UserRole) if first else "" + if not run_id: + return + folder = taskrepo.ARTIFACTS_DIR / self._task["task_id"] / run_id + if folder.exists(): + open_path(str(folder)) + + +class _DropZone(QLabel): + """Drag-an-.xlsx-here area for the Import tab.""" + + file_dropped = Signal(str) + + def __init__(self): + super().__init__() + self.setAlignment(Qt.AlignCenter) + self.setMinimumHeight(70) + _p = current_palette() + self.setStyleSheet( + f"QLabel {{ border: 1px dashed {_p.border_strong};" + f" border-radius: {_p.radius_lg}px;" + f" color: {_p.text_muted}; padding: 10px; }}") + self.setAcceptDrops(True) + + def dragEnterEvent(self, event): # noqa: N802 + urls = event.mimeData().urls() + if urls and urls[0].toLocalFile().lower().endswith( + (".xlsx", ".xlsm", ".xls", ".csv", ".json")): + event.acceptProposedAction() + + def dropEvent(self, event): # noqa: N802 + urls = event.mimeData().urls() + if urls: + self.file_dropped.emit(urls[0].toLocalFile()) + + +class _AiCreateDialog(QDialog): + """Create tasks two ways, one tab each (both preview first — nothing is + saved until the user confirms): ✨ AI gen from a natural-language + description, or 📥 Import from a filled Excel template (pick or drag).""" + + def __init__(self, ctx: AppContext, parent=None): + super().__init__(parent) + from PySide6.QtWidgets import QTabWidget + + self.ctx = ctx + self.created_tasks: List[dict] = [] + self._planned: List[dict] = [] + self._worker: Optional[AgentWorker] = None + self.setWindowTitle(tr("schedtask.ai_btn")) + self.resize(600, 520) + + root = QVBoxLayout(self) + ws_row = QHBoxLayout() + ws_row.addWidget(QLabel(tr("schedtask.f_workspace"))) + self.workspace_combo = QComboBox() + self.workspace_combo.addItem(tr("schedtask.no_workspace"), "") + for p in list_projects(): + self.workspace_combo.addItem(p.name, p.project_id) + self.workspace_combo.setToolTip(tr("schedtask.hint_workspace")) + ws_row.addWidget(self.workspace_combo, 1) + root.addLayout(ws_row) + self.tabs = QTabWidget() + root.addWidget(self.tabs, 1) + + # ---- tab 1: AI gen ------------------------------------------------ + ai_page = QWidget() + al = QVBoxLayout(ai_page) + al.addWidget(QLabel(tr("schedtask.ai_desc_label"))) + self.desc_edit = QPlainTextEdit() + self.desc_edit.setPlaceholderText(tr("schedtask.ai_desc_ph")) + self.desc_edit.setMaximumHeight(110) + al.addWidget(self.desc_edit) + # Attachments (files + links) — merged into every task this generates, + # AND into the planning prompt so the AI knows they exist. + attach_row = QHBoxLayout() + self.ai_files_edit = QLineEdit() + self.ai_files_edit.setPlaceholderText(tr("schedtask.files_placeholder")) + ai_pick_btn = QPushButton(tr("schedtask.pick_files")) + ai_pick_btn.setIcon(icon("folder")) + ai_pick_btn.clicked.connect(self._ai_pick_files) + attach_row.addWidget(self.ai_files_edit, 1) + attach_row.addWidget(ai_pick_btn) + al.addWidget(QLabel(tr("schedtask.f_files"))) + al.addLayout(attach_row) + self.ai_links_edit = QLineEdit() + self.ai_links_edit.setPlaceholderText(tr("schedtask.links_placeholder")) + al.addWidget(QLabel(tr("schedtask.f_links"))) + al.addWidget(self.ai_links_edit) + self.gen_btn = QPushButton(tr("schedtask.ai_generate")) + self.gen_btn.setIcon(icon("sparkle")) + self.gen_btn.setObjectName("primary") + self.gen_btn.clicked.connect(self._generate) + al.addWidget(self.gen_btn) + al.addWidget(QLabel(tr("schedtask.ai_preview_label"))) + self.preview = QPlainTextEdit() + self.preview.setReadOnly(True) + al.addWidget(self.preview, 1) + self.tabs.addTab(ai_page, tr("schedtask.tab_ai")) + + # ---- tab 2: Import from Excel -------------------------------------- + imp_page = QWidget() + il = QVBoxLayout(imp_page) + tpl_btn = QPushButton(tr("schedtask.export_template_btn")) + tpl_btn.setIcon(icon("upload")) + tpl_btn.clicked.connect(self._export_template) + il.addWidget(tpl_btn) + pick_row = QHBoxLayout() + pick_btn = QPushButton(tr("schedtask.import_pick_btn")) + pick_btn.setIcon(icon("folder")) + pick_btn.clicked.connect(self._pick_import_file) + pick_row.addWidget(pick_btn) + pick_row.addStretch(1) + il.addLayout(pick_row) + self.drop_zone = _DropZone() + self.drop_zone.setText(tr("schedtask.drop_hint")) + self.drop_zone.file_dropped.connect(self._load_import_file) + il.addWidget(self.drop_zone) + il.addWidget(QLabel(tr("schedtask.ai_preview_label"))) + self.import_preview = QPlainTextEdit() + self.import_preview.setReadOnly(True) + il.addWidget(self.import_preview, 1) + self.tabs.addTab(imp_page, tr("schedtask.tab_import")) + + self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) + self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) + self.buttons.accepted.connect(self._confirm) + self.buttons.rejected.connect(self.reject) + root.addWidget(self.buttons) + + # ---- Import tab ------------------------------------------------------ + def _export_template(self) -> None: + from PySide6.QtWidgets import QFileDialog + + from ..core.task_excel import export_template + + path, _ = QFileDialog.getSaveFileName( + self, tr("schedtask.export_template_btn"), + "cowork_tasks_template.xlsx", "Excel (*.xlsx)") + if not path: + return + try: + export_template(path) + open_path(str(Path(path).parent)) + except Exception as exc: # noqa: BLE001 + QMessageBox.warning(self, tr("schedtask.tab_import"), str(exc)) + + def _pick_import_file(self) -> None: + from PySide6.QtWidgets import QFileDialog + + from ..core.task_import import IMPORT_FILTER + + path, _ = QFileDialog.getOpenFileName( + self, tr("schedtask.import_pick_btn"), "", IMPORT_FILTER) + if path: + self._load_import_file(path) + + def _load_import_file(self, path: str) -> None: + from ..core.task_import import import_tasks + + try: + self._planned = import_tasks(path) + except ValueError as exc: + self.import_preview.setPlainText(str(exc)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) + return + by_id = {t["task_id"]: t["title"] for t in self._planned} + lines = [] + for i, t in enumerate(self._planned, 1): + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") + deps = t.get("dependency", {}).get("depends_on") or [] + dep_note = (" ← depends: " + ", ".join(by_id.get(d, "?") for d in deps)) if deps else "" + lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" + f" {when} repeat={sched.get('repeat_type', 'none')}{dep_note}") + self.import_preview.setPlainText("\n\n".join(lines)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) + + def _ai_pick_files(self) -> None: + from PySide6.QtWidgets import QFileDialog + + files, _ = QFileDialog.getOpenFileNames(self, tr("schedtask.pick_files")) + if files: + existing = [f for f in self.ai_files_edit.text().split(";") if f.strip()] + self.ai_files_edit.setText("; ".join(existing + files)) + + def _attached_files(self) -> List[str]: + return [p.strip() for p in self.ai_files_edit.text().split(";") if p.strip()] + + def _attached_links(self) -> List[str]: + return [u.strip() for u in self.ai_links_edit.text().split(";") if u.strip()] + + def _generate(self) -> None: + description = self.desc_edit.toPlainText().strip() + if not description or self._worker is not None: + return + files, links = self._attached_files(), self._attached_links() + self.gen_btn.setEnabled(False) + self.gen_btn.setText(tr("schedtask.ai_generating")) + + def job(worker: AgentWorker): + from ..core.ai_task_planner import plan_tasks + + provider = self.ctx.build_active_provider() + full_desc = description + if files or links: + attach_note = "; ".join(files + links) + full_desc += f"\n\n(Attached references available: {attach_note})" + planned = plan_tasks(provider, full_desc, cancel=worker.is_cancelled) + # Attachments apply to every generated task so they're available + # at RUN time too, not just visible to the planner. + for t in planned: + t["input"]["file_paths"] = list(files) + t["input"]["links"] = list(links) + return {"tasks": planned} + + w = AgentWorker(job) + w.finished_ok.connect(self._on_planned) + w.failed.connect(self._on_failed) + self._worker = w + w.start() + + def _on_planned(self, result: dict) -> None: + self._worker = None + self.gen_btn.setEnabled(True) + self.gen_btn.setText(tr("schedtask.ai_generate")) + self._planned = result.get("tasks") or [] + lines = [] + for i, t in enumerate(self._planned, 1): + sched = t.get("schedule", {}) + when = sched.get("run_at") if sched.get("enabled") else tr("schedtask.no_schedule") + dep = t.get("dependency", {}) + chain = f" ← {dep.get('previous_task_id', '')[:8]}" if dep.get("previous_task_id") else "" + lines.append(f"{i}. [{t.get('task_type')}] {t.get('title')}\n" + f" {when} repeat={sched.get('repeat_type', 'none')}{chain}\n" + f" {t.get('description', '')[:150]}") + self.preview.setPlainText("\n\n".join(lines)) + self.buttons.button(QDialogButtonBox.Ok).setEnabled(bool(self._planned)) + + def _on_failed(self, err: str) -> None: + self._worker = None + self.gen_btn.setEnabled(True) + self.gen_btn.setText(tr("schedtask.ai_generate")) + self.preview.setPlainText(str(err)) + + def _confirm(self) -> None: + project_id = self.workspace_combo.currentData() or "" + for t in self._planned: + t["project_id"] = project_id + self.created_tasks = self._planned + self.accept() diff --git a/ui/settings_dialog.py b/ui/settings_dialog.py index 1c1abd0..37418bf 100644 --- a/ui/settings_dialog.py +++ b/ui/settings_dialog.py @@ -1,649 +1,648 @@ -"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group -(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place), -and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps).""" -from __future__ import annotations - -from typing import Dict - -from PySide6.QtCore import Qt -from PySide6.QtGui import QGuiApplication -from PySide6.QtWidgets import ( - QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, - QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, - QMessageBox, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem, - QVBoxLayout, QWidget, -) - -from ..config import PROVIDER_LABELS -from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES -from ..core.worker import AgentWorker -from ..i18n import LANGUAGES, tr -from ..state import AppContext -from .icons import icon, IconLabel -from .ext_connector_dialog import ExtConnectorEditDialog - - -class SettingsDialog(QDialog): - def __init__(self, ctx, parent=None): - super().__init__() - self.ctx = ctx - self.setWindowTitle(tr("settings.title")) - self.setMinimumWidth(560) - self.setWindowFlags( - self.windowFlags() - | Qt.WindowMinimizeButtonHint - | Qt.WindowMaximizeButtonHint - ) - self.setSizeGripEnabled(True) - self.setStyleSheet( - "QGroupBox { background: transparent;" - " border: 1px solid rgba(140,146,152,0.35); }") - data = ctx.config.data - - outer = QVBoxLayout(self) - scroll = QScrollArea() - scroll.setWidgetResizable(True) - self._content = QWidget() - root = QVBoxLayout(self._content) - - # --- language + tray --- - top = QFormLayout() - self.language_combo = QComboBox() - for key, label in LANGUAGES.items(): - self.language_combo.addItem(label, key) - self._select_combo(self.language_combo, ctx.config.language) - top.addRow(tr("settings.language"), self.language_combo) - - self.tray_chk = QCheckBox(tr("settings.tray_keep")) - self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) - top.addRow("", self.tray_chk) - self.notify_chk = QCheckBox(tr("settings.tray_notify")) - self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True))) - top.addRow("", self.notify_chk) - root.addLayout(top) - - self._load_workers = [] - - # --- AI Provider --- - self._prov_staging: Dict[str, dict] = { - key: dict(conf) for key, conf in data["providers"].items() - } - self.provider_combo = QComboBox() - for key, label in PROVIDER_LABELS.items(): - self.provider_combo.addItem(label, key) - self._select_combo(self.provider_combo, ctx.config.active_provider) - self._prov_current_key = self.provider_combo.currentData() - - conf = self._prov_staging.get(self._prov_current_key, {}) - self.prov_base = QLineEdit(conf.get("base_url", "")) - self.prov_key = self._secret(conf.get("api_key", "")) - self.prov_model = self._model_combo(conf.get("model", "")) - self.prov_status = QLabel("") - self.prov_status.setObjectName("hint") - self.prov_status.setWordWrap(True) - prov_group = self._group(tr("settings.group.provider"), [ - (tr("settings.active_provider"), self.provider_combo), - (tr("settings.base_url"), self.prov_base), - (tr("settings.api_key"), self.prov_key), - (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)), - ]) - prov_group.layout().addRow("", self.prov_status) - self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed) - root.addWidget(prov_group) - - # --- Sandbox Security Layer --- - sec = ctx.config.agent_security - self.sandbox_group = QGroupBox(tr("settings.group.sandbox")) - sbl = QVBoxLayout(self.sandbox_group) - - # --- Password protection for Sandbox Security (at top) --- - self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password") - sbl.addWidget(self.sandbox_pw_label) - - pw_row = QHBoxLayout() - self.sandbox_pw_edit = QLineEdit("") - self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings") - self.sandbox_pw_edit.setEchoMode(QLineEdit.Password) - pw_row.addWidget(self.sandbox_pw_edit, 1) - self.sandbox_unlock_btn = QPushButton("Unlock") - self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock) - pw_row.addWidget(self.sandbox_unlock_btn) - self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00") - self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;") - pw_row.addWidget(self.sandbox_locked_status) - sbl.addLayout(pw_row) - self._sandbox_unlocked = False # Start LOCKED — must enter password first - self._sandbox_pw = sec.get("sandbox_pw", "") - - # Separator line between pw section and sandbox settings - pw_sep = QLabel("────────────────") - sbl.addWidget(pw_sep) - - self.sandbox_confirm = QCheckBox(tr("settings.sandbox_confirm_commands")) - self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) - self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) - sbl.addWidget(self.sandbox_confirm) - - self.sandbox_block_network = QCheckBox(tr("settings.sandbox_block_network")) - self.sandbox_block_network.setChecked(bool(sec.get("block_network", True))) - self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip")) - sbl.addWidget(self.sandbox_block_network) - - # "Allow the agent to fetch URLs" + the live "Test Internet" self-test - # moved to Monitoring → Tools → Tool (they govern a tool capability, so - # they belong with the other tool toggles — see ToolsAdminTab). - - # --- Enable/Disable Agent Security --- - self.sec_enabled = QCheckBox("Enable Agent Security (command validation)") - self.sec_enabled.setChecked(bool(sec.get("enabled", True))) - self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") - sbl.addWidget(self.sec_enabled) - - # --- AI Command Check toggle --- - self.ai_check = QCheckBox("AI check commands") - self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) - self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") - sbl.addWidget(self.ai_check) - - # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group - # below — see _param_section("settings.group.sandbox_limits"). - - # Collect all sandbox-editable widgets and lock them until unlocked - self._sandbox_widgets = [ - self.sandbox_confirm, self.sandbox_block_network, - self.ai_check, self.sec_enabled, - ] - for _w in self._sandbox_widgets: - _w.setEnabled(False) - - root.addWidget(self.sandbox_group) - - # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools - # → Connector now — no connector UI in Settings. (_ms365_workers is kept - # for the dead-but-retained MS365 OAuth sign-in handlers below.) - self._ms365_workers = [] - - # --- Parameter --- - param_group = QGroupBox(tr("settings.group.parameter")) - pgl = QFormLayout(param_group) - - def _param_section(key: str) -> None: - lbl = QLabel(tr(key)) - lbl.setStyleSheet("font-weight:600; margin-top:6px;") - pgl.addRow(lbl) - - # Parallel-conversation limit removed — conversations and flows now run - # unlimited in parallel (no cap, no Settings row). - att = data.get("attachments", {}) - _param_section("settings.group.attachments") - self.attach_files = QSpinBox() - self.attach_files.setRange(1, 50) - self.attach_files.setSuffix(tr("settings.max_files_suffix")) - self.attach_files.setValue(max(1, int(att.get("max_files", 20)))) - self.attach_files.setToolTip(tr("settings.max_files_tooltip")) - self.attach_tokens = QSpinBox() - self.attach_tokens.setRange(1, 1000) - self.attach_tokens.setSingleStep(5) - self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix")) - self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000)) - self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip")) - pgl.addRow(tr("settings.max_files"), self.attach_files) - pgl.addRow(tr("settings.max_per_file"), self.attach_tokens) - - st = data.get("structure", {}) - _param_section("settings.group.structure") - self.struct_nodes = QSpinBox() - self.struct_nodes.setRange(0, 100000) - self.struct_nodes.setSpecialValueText(tr("settings.unlimited")) - self.struct_nodes.setSuffix(tr("settings.nodes_suffix")) - self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500)))) - self.struct_nodes.setToolTip(tr("settings.nodes_tooltip")) - self.struct_edges = QSpinBox() - self.struct_edges.setRange(0, 200000) - self.struct_edges.setSpecialValueText(tr("settings.unlimited")) - self.struct_edges.setSuffix(tr("settings.edges_suffix")) - self.struct_edges.setValue(max(0, int(st.get("max_edges", 500)))) - self.struct_edges.setToolTip(tr("settings.edges_tooltip")) - pgl.addRow(tr("settings.max_nodes"), self.struct_nodes) - pgl.addRow(tr("settings.max_edges"), self.struct_edges) - - # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from - # the Sandbox Security group; still stored under agent_security.*. - _param_section("settings.group.sandbox_limits") - self.sandbox_cpu = QSpinBox() - self.sandbox_cpu.setRange(0, 100_000) - self.sandbox_cpu.setSuffix(" %") - self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited")) - self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0)) - pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu) - - self.sandbox_memory = QSpinBox() - self.sandbox_memory.setRange(0, 1_000_000) - self.sandbox_memory.setSuffix(" MB") - self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited")) - self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048)) - pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory) - - self.sandbox_disk = QSpinBox() - self.sandbox_disk.setRange(0, 1_000_000) - self.sandbox_disk.setSuffix(" MB") - self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited")) - self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048)) - pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk) - - root.addWidget(param_group) - - # ---- Auto Model Routing ------------------------------------------ - routing = self.ctx.config.routing - routing_group = QGroupBox(tr("routing.settings_group")) - rgl = QFormLayout(routing_group) - - self.routing_mode = QComboBox() - for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"), - ("manual", "routing.mode_manual")): - self.routing_mode.addItem(tr(key), value) - self._select_combo(self.routing_mode, routing.get("switch_mode", "off")) - rgl.addRow(tr("routing.settings_mode"), self.routing_mode) - - self.routing_policy = QComboBox() - for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), - ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")): - self.routing_policy.addItem(tr(key), value) - self._select_combo(self.routing_policy, routing.get("policy", "balanced")) - rgl.addRow(tr("routing.settings_policy"), self.routing_policy) - - # Min score gain stored as a fraction (0..1); shown as a percentage. - self.routing_min_gain = QSpinBox() - self.routing_min_gain.setRange(0, 100) - self.routing_min_gain.setSuffix(" %") - self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100))) - rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain) - - self.routing_timeout = QSpinBox() - self.routing_timeout.setRange(5, 600) - self.routing_timeout.setSuffix(" s") - self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60)) - rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout) - - self.routing_interval = QSpinBox() - self.routing_interval.setRange(0, 720) - self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled - self.routing_interval.setSuffix(" h") - self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0)) - rgl.addRow(tr("routing.settings_interval"), self.routing_interval) - - self.routing_concurrency = QSpinBox() - self.routing_concurrency.setRange(1, 16) - self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2)) - rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency) - - self.routing_judge = QLineEdit(routing.get("judge_model", "")) - rgl.addRow(tr("routing.settings_judge"), self.routing_judge) - - self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now")) - self.routing_reassess_btn.clicked.connect(self._routing_reassess_now) - rgl.addRow("", self.routing_reassess_btn) - - rhint = QLabel(tr("routing.settings_hint")) - rhint.setObjectName("hint") - rhint.setWordWrap(True) - rgl.addRow(rhint) - root.addWidget(routing_group) - - note = QLabel(tr("settings.tip")) - note.setObjectName("hint") - root.addWidget(note) - - scroll.setWidget(self._content) - outer.addWidget(scroll, 1) - - buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) - buttons.accepted.connect(self._save) - buttons.rejected.connect(self.reject) - outer.addWidget(buttons) - - from .widgets import guard_wheel - guard_wheel(self) - - screen = QGuiApplication.primaryScreen() - if screen: - avail = screen.availableGeometry() - self.resize(640, min(740, avail.height() - 80)) - self.setMaximumHeight(avail.height()) - - # ---- helpers ----------------------------------------------------- - @staticmethod - def _secret(value: str) -> QLineEdit: - edit = QLineEdit(value) - edit.setEchoMode(QLineEdit.Password) - return edit - - @staticmethod - def _select_combo(combo: QComboBox, value: str) -> None: - idx = combo.findData(value) - if idx >= 0: - combo.setCurrentIndex(idx) - - @staticmethod - def _group(title: str, rows) -> QGroupBox: - box = QGroupBox(title) - form = QFormLayout(box) - for label, widget in rows: - form.addRow(label, widget) - return box - - def _routing_reassess_now(self) -> None: - """Kick off a manual model reassessment in the background.""" - try: - service = self.ctx.routing() - if service.is_reassessing(): - return - self.routing_reassess_btn.setEnabled(False) - self.routing_reassess_btn.setText(tr("routing.reassessing")) - - def _done(result) -> None: - # Re-enable from the (worker) callback; label reflects the count. - self.routing_reassess_btn.setEnabled(True) - self.routing_reassess_btn.setText( - tr("routing.reassess_done", count=len(result or {}))) - - service.reassess_background(on_done=_done) - except Exception: # noqa: BLE001 — a reassess click must never crash Settings - self.routing_reassess_btn.setEnabled(True) - self.routing_reassess_btn.setText(tr("routing.settings_reassess_now")) - - @staticmethod - def _model_combo(value: str) -> QComboBox: - combo = QComboBox() - combo.setEditable(True) - if value: - combo.addItem(value) - combo.setCurrentText(value) - return combo - - def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget: - row = QWidget() - lay = QHBoxLayout(row) - lay.setContentsMargins(0, 0, 0, 0) - lay.addWidget(combo, 1) - btn = QPushButton(tr("settings.load")) - btn.setIcon(icon("download")) - btn.setToolTip(tr("settings.load_tooltip")) - btn.clicked.connect( - lambda: self._load_models(self.provider_combo.currentData(), combo, status)) - lay.addWidget(btn) - test_btn = QPushButton(tr("settings.test_connection")) - test_btn.setIcon(icon("flask")) - test_btn.setToolTip(tr("settings.test_connection_tooltip")) - test_btn.clicked.connect( - lambda: self._test_connection(self.provider_combo.currentData(), status)) - lay.addWidget(test_btn) - return row - - def _stash_provider_fields(self) -> None: - staged = self._prov_staging.setdefault(self._prov_current_key, {}) - staged.update({ - "base_url": self.prov_base.text().strip(), - "api_key": self.prov_key.text(), - "model": self.prov_model.currentText().strip(), - }) - - def _on_provider_edit_changed(self) -> None: - self._stash_provider_fields() - self._prov_current_key = self.provider_combo.currentData() - conf = self._prov_staging.get(self._prov_current_key, {}) - self.prov_base.setText(conf.get("base_url", "")) - self.prov_key.setText(conf.get("api_key", "")) - self.prov_model.clear() - if conf.get("model"): - self.prov_model.addItem(conf["model"]) - self.prov_model.setCurrentText(conf["model"]) - else: - self.prov_model.setCurrentText("") - self.prov_status.setText("") - - def _current_conf(self, provider: str) -> dict: - if provider == self._prov_current_key: - return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(), - "model": self.prov_model.currentText().strip()} - conf = self._prov_staging.get(provider, {}) - return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""), - "model": conf.get("model", "")} - - # ---- MS365 zero-config sign-in ("connect like Claude") --------------- - def _refresh_ms365_status(self) -> None: - from ..core.ms365_auth import current_identity - who = current_identity(self.ctx.config) - if who: - self.ms365_status.setText(tr("settings.ms365_signed_in", who=who)) - self.ms365_signin_btn.setEnabled(False) - self.ms365_signout_btn.setEnabled(True) - else: - self.ms365_status.setText(tr("settings.ms365_signed_out")) - self.ms365_signin_btn.setEnabled(True) - self.ms365_signout_btn.setEnabled(False) - self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn")) - self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn")) - - def _ms365_sign_in(self) -> None: - from ..core.ms365_auth import current_identity, sign_in - self.ms365_signin_btn.setEnabled(False) - self.ms365_status.setText(tr("settings.ms365_signing_in")) - cfg = self.ctx.config - - def job(worker): - # on_code fires (worker thread) with the MSAL device-flow dict — - # marshal it to the UI thread via the worker's event signal. - return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg) - - def on_event(ev: dict) -> None: - if "device_flow" in ev: - self._show_ms365_device_code(ev["device_flow"]) - - def done(_result) -> None: - self._close_ms365_code_dialog() - self.ctx.save() - self._refresh_ms365_status() - QMessageBox.information( - self, tr("settings.ms365_signin_btn"), - tr("settings.ms365_signed_in", who=current_identity(cfg))) - - def failed(err: str) -> None: - self._close_ms365_code_dialog() - self._refresh_ms365_status() - QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err) - - w = AgentWorker(job) - w.event.connect(on_event) - w.finished_ok.connect(done) - w.failed.connect(failed) - self._ms365_workers.append(w) - w.start() - - def _close_ms365_code_dialog(self) -> None: - dlg = getattr(self, "_ms365_code_dialog", None) - if dlg is not None: - dlg.close() - self._ms365_code_dialog = None - - def _show_ms365_device_code(self, flow: dict) -> None: - """Auto-open the sign-in page + show the one-time code in a COPYABLE, - non-modal dialog (so the worker keeps polling and can auto-close it on - success). The code is also copied to the clipboard immediately.""" - import webbrowser - - code = flow.get("user_code", "") - url = flow.get("verification_uri", "https://microsoft.com/devicelogin") - # Auto-copy the code so the user can just paste it. - QGuiApplication.clipboard().setText(code) - # Auto-open the browser to the (code-prefilled, if available) sign-in page. - try: - webbrowser.open(flow.get("verification_uri_complete") or url) - except Exception: # noqa: BLE001 — a headless box just shows the link to click - pass - - self._close_ms365_code_dialog() - dlg = QDialog(self) - dlg.setWindowTitle(tr("settings.ms365_signin_btn")) - dlg.setMinimumWidth(420) - lay = QVBoxLayout(dlg) - info = QLabel(tr("settings.ms365_code_hint", url=url)) - info.setWordWrap(True) - info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction) - info.setOpenExternalLinks(True) - lay.addWidget(info) - - code_row = QHBoxLayout() - code_edit = QLineEdit(code) - code_edit.setReadOnly(True) - f = code_edit.font() - f.setPointSize(f.pointSize() + 4) - f.setBold(True) - code_edit.setFont(f) - code_edit.setCursorPosition(0) - copy_btn = QPushButton(tr("settings.ms365_copy_code")) - copy_btn.setIcon(icon("document")) - copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code)) - open_btn = QPushButton(tr("settings.ms365_open_link")) - open_btn.setIcon(icon("link")) - open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url)) - code_row.addWidget(code_edit, 1) - code_row.addWidget(copy_btn) - code_row.addWidget(open_btn) - lay.addLayout(code_row) - - buttons = QDialogButtonBox(QDialogButtonBox.Close) - buttons.rejected.connect(dlg.reject) - lay.addWidget(buttons) - - self._ms365_code_dialog = dlg - dlg.show() # non-modal — sign-in polling continues; done() closes it - - def _ms365_sign_out(self) -> None: - from ..core.ms365_auth import sign_out_default - sign_out_default(self.ctx.config) - self._refresh_ms365_status() - - def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None: - conf = self._current_conf(provider) - - def job(worker): - from ..providers import build_provider - prov = build_provider(provider, conf) - models = prov.list_models() - return {"models": models, "error": getattr(prov, "last_error", "")} - - def done(result): - models = result.get("models") or [] - current = combo.currentText().strip() - combo.clear() - if current: - combo.addItem(current) - for m in models: - if m != current: - combo.addItem(m) - combo.setCurrentText(current) - error = result.get("error", "") - if models: - status.setText(tr("settings.loaded_models", n=len(models), - provider=PROVIDER_LABELS.get(provider, provider))) - else: - status.setText(tr("settings.load_models_error", err=error or - tr("settings.load_models_error_unknown"))) - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e))) - self._load_workers.append(w) - status.setText(tr("settings.loading_models")) - w.start() - - def _test_connection(self, provider: str, status: QLabel) -> None: - conf = self._current_conf(provider) - - def job(worker): - from ..providers import build_provider - ok, message = build_provider(provider, conf).test_connection() - return {"ok": ok, "message": message} - - def done(result): - ok = result.get("ok") - status.setText(result.get("message", "")) - status.setStyleSheet("color: #090;" if ok else "color: #c00;") - - def failed(e): - status.setText(str(e)) - status.setStyleSheet("color: #c00;") - - w = AgentWorker(job) - w.finished_ok.connect(done) - w.failed.connect(failed) - self._load_workers.append(w) - status.setText(tr("settings.testing_connection")) - w.start() - - def _sandbox_unlock(self) -> None: - pw = self.sandbox_pw_edit.text() - if pw and pw == self._sandbox_pw: - self._sandbox_unlocked = True - self.sandbox_locked_status.setText("Unlocked") - self.sandbox_locked_status.set_icon("unlock", "#090") - self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;") - # Enable all sandbox widgets - for w in self._sandbox_widgets: - w.setEnabled(True) - QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.") - else: - QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.") - - def _save(self) -> None: - data = self.ctx.config.data - data["active_provider"] = self.provider_combo.currentData() - data["language"] = self.language_combo.currentData() - - self._stash_provider_fields() - for key, staged in self._prov_staging.items(): - data["providers"].setdefault(key, {}).update({ - "base_url": staged.get("base_url", ""), - "api_key": staged.get("api_key", ""), - "model": staged.get("model", ""), - }) - - # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now - # (persisted there directly), so it is intentionally not written here. - data.setdefault("agent_security", {}).update({ - "enabled": self.sec_enabled.isChecked(), - "cowork_confirm_commands": self.sandbox_confirm.isChecked(), - "block_network": self.sandbox_block_network.isChecked(), - "command_ai_check": self.ai_check.isChecked(), - "command_whitelist": [], - "resource_limit_cpu_percent": self.sandbox_cpu.value(), - "resource_limit_memory_mb": self.sandbox_memory.value(), - "resource_limit_disk_mb": self.sandbox_disk.value(), - }) - att = data.setdefault("attachments", {}) - att["max_tokens"] = self.attach_tokens.value() * 1000 - att["max_files"] = self.attach_files.value() - st = data.setdefault("structure", {}) - st["max_nodes"] = self.struct_nodes.value() - st["max_edges"] = self.struct_edges.value() - tray = data.setdefault("tray", {}) - tray["minimize_on_close"] = self.tray_chk.isChecked() - tray["notify_on_done"] = self.notify_chk.isChecked() - - r = data.setdefault("routing", {}) - r["switch_mode"] = self.routing_mode.currentData() - r["policy"] = self.routing_policy.currentData() - r["min_score_gain"] = self.routing_min_gain.value() / 100.0 - r["confirm_timeout_sec"] = self.routing_timeout.value() - r["reassess_interval_hours"] = self.routing_interval.value() - r["per_provider_concurrency"] = self.routing_concurrency.value() - r["judge_model"] = self.routing_judge.text().strip() - - self.ctx.save() - - # Force-reload config so all parts of the app pick up the new settings immediately - self.ctx.config._data = None # invalidate cache - self.ctx.config._agent_security = None - - self.accept() +"""Settings dialog: AI provider, Sandbox, the unified Connectors (MCP) group +(CAD / CAE / MS365 / Other — MCP servers + REST connectors in one place), +and the merged "Parameter" group (Cowork / Attachments / GraphRAG caps).""" +from __future__ import annotations + +from typing import Dict + +from PySide6.QtCore import Qt +from PySide6.QtGui import QGuiApplication +from PySide6.QtWidgets import ( + QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout, + QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem, + QMessageBox, QPushButton, QScrollArea, QSpinBox, QTreeWidget, QTreeWidgetItem, + QVBoxLayout, QWidget, +) + +from ..config import PROVIDER_LABELS +from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES +from ..core.worker import AgentWorker +from ..i18n import LANGUAGES, tr +from ..state import AppContext +from .icons import icon, IconLabel +from .ext_connector_dialog import ExtConnectorEditDialog + + +class SettingsDialog(QDialog): + def __init__(self, ctx, parent=None): + super().__init__() + self.ctx = ctx + self.setWindowTitle(tr("settings.title")) + self.setMinimumWidth(560) + self.setWindowFlags( + self.windowFlags() + | Qt.WindowMinimizeButtonHint + | Qt.WindowMaximizeButtonHint + ) + self.setSizeGripEnabled(True) + # Group boxes are styled app-wide (see theme._TEMPLATE); this dialog + # used to re-declare them and drifted out of sync with the rest. + data = ctx.config.data + + outer = QVBoxLayout(self) + scroll = QScrollArea() + scroll.setWidgetResizable(True) + self._content = QWidget() + root = QVBoxLayout(self._content) + + # --- language + tray --- + top = QFormLayout() + self.language_combo = QComboBox() + for key, label in LANGUAGES.items(): + self.language_combo.addItem(label, key) + self._select_combo(self.language_combo, ctx.config.language) + top.addRow(tr("settings.language"), self.language_combo) + + self.tray_chk = QCheckBox(tr("settings.tray_keep")) + self.tray_chk.setChecked(bool(data.get("tray", {}).get("minimize_on_close", True))) + top.addRow("", self.tray_chk) + self.notify_chk = QCheckBox(tr("settings.tray_notify")) + self.notify_chk.setChecked(bool(data.get("tray", {}).get("notify_on_done", True))) + top.addRow("", self.notify_chk) + root.addLayout(top) + + self._load_workers = [] + + # --- AI Provider --- + self._prov_staging: Dict[str, dict] = { + key: dict(conf) for key, conf in data["providers"].items() + } + self.provider_combo = QComboBox() + for key, label in PROVIDER_LABELS.items(): + self.provider_combo.addItem(label, key) + self._select_combo(self.provider_combo, ctx.config.active_provider) + self._prov_current_key = self.provider_combo.currentData() + + conf = self._prov_staging.get(self._prov_current_key, {}) + self.prov_base = QLineEdit(conf.get("base_url", "")) + self.prov_key = self._secret(conf.get("api_key", "")) + self.prov_model = self._model_combo(conf.get("model", "")) + self.prov_status = QLabel("") + self.prov_status.setObjectName("hint") + self.prov_status.setWordWrap(True) + prov_group = self._group(tr("settings.group.provider"), [ + (tr("settings.active_provider"), self.provider_combo), + (tr("settings.base_url"), self.prov_base), + (tr("settings.api_key"), self.prov_key), + (tr("settings.model"), self._with_load(self.prov_model, self.prov_status)), + ]) + prov_group.layout().addRow("", self.prov_status) + self.provider_combo.currentIndexChanged.connect(self._on_provider_edit_changed) + root.addWidget(prov_group) + + # --- Sandbox Security Layer --- + sec = ctx.config.agent_security + self.sandbox_group = QGroupBox(tr("settings.group.sandbox")) + sbl = QVBoxLayout(self.sandbox_group) + + # --- Password protection for Sandbox Security (at top) --- + self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password") + sbl.addWidget(self.sandbox_pw_label) + + pw_row = QHBoxLayout() + self.sandbox_pw_edit = QLineEdit("") + self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings") + self.sandbox_pw_edit.setEchoMode(QLineEdit.Password) + pw_row.addWidget(self.sandbox_pw_edit, 1) + self.sandbox_unlock_btn = QPushButton("Unlock") + self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock) + pw_row.addWidget(self.sandbox_unlock_btn) + self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00") + self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;") + pw_row.addWidget(self.sandbox_locked_status) + sbl.addLayout(pw_row) + self._sandbox_unlocked = False # Start LOCKED — must enter password first + self._sandbox_pw = sec.get("sandbox_pw", "quandh14") + + # Separator line between pw section and sandbox settings + pw_sep = QLabel("────────────────") + sbl.addWidget(pw_sep) + + self.sandbox_confirm = QCheckBox(tr("settings.sandbox_confirm_commands")) + self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False))) + self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip")) + sbl.addWidget(self.sandbox_confirm) + + self.sandbox_block_network = QCheckBox(tr("settings.sandbox_block_network")) + self.sandbox_block_network.setChecked(bool(sec.get("block_network", True))) + self.sandbox_block_network.setToolTip(tr("settings.sandbox_block_network_tooltip")) + sbl.addWidget(self.sandbox_block_network) + + # "Allow the agent to fetch URLs" + the live "Test Internet" self-test + # moved to Monitoring → Tools → Tool (they govern a tool capability, so + # they belong with the other tool toggles — see ToolsAdminTab). + + # --- Enable/Disable Agent Security --- + self.sec_enabled = QCheckBox("Enable Agent Security (command validation)") + self.sec_enabled.setChecked(bool(sec.get("enabled", True))) + self.sec_enabled.setToolTip("Bật/tắt toàn bộ Agent Security") + sbl.addWidget(self.sec_enabled) + + # --- AI Command Check toggle --- + self.ai_check = QCheckBox("AI check commands") + self.ai_check.setChecked(bool(sec.get("command_ai_check", False))) + self.ai_check.setToolTip("Cho AI control-agent xét lệnh trước khi chạy") + sbl.addWidget(self.ai_check) + + # Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group + # below — see _param_section("settings.group.sandbox_limits"). + + # Collect all sandbox-editable widgets and lock them until unlocked + self._sandbox_widgets = [ + self.sandbox_confirm, self.sandbox_block_network, + self.ai_check, self.sec_enabled, + ] + for _w in self._sandbox_widgets: + _w.setEnabled(False) + + root.addWidget(self.sandbox_group) + + # Connectors (MCP / REST API) are managed entirely in Monitoring → Tools + # → Connector now — no connector UI in Settings. (_ms365_workers is kept + # for the dead-but-retained MS365 OAuth sign-in handlers below.) + self._ms365_workers = [] + + # --- Parameter --- + param_group = QGroupBox(tr("settings.group.parameter")) + pgl = QFormLayout(param_group) + + def _param_section(key: str) -> None: + lbl = QLabel(tr(key)) + lbl.setStyleSheet("font-weight:600; margin-top:6px;") + pgl.addRow(lbl) + + # Parallel-conversation limit removed — conversations and flows now run + # unlimited in parallel (no cap, no Settings row). + att = data.get("attachments", {}) + _param_section("settings.group.attachments") + self.attach_files = QSpinBox() + self.attach_files.setRange(1, 50) + self.attach_files.setSuffix(tr("settings.max_files_suffix")) + self.attach_files.setValue(max(1, int(att.get("max_files", 20)))) + self.attach_files.setToolTip(tr("settings.max_files_tooltip")) + self.attach_tokens = QSpinBox() + self.attach_tokens.setRange(1, 1000) + self.attach_tokens.setSingleStep(5) + self.attach_tokens.setSuffix(tr("settings.max_per_file_suffix")) + self.attach_tokens.setValue(max(1, int(att.get("max_tokens", 500000)) // 1000)) + self.attach_tokens.setToolTip(tr("settings.max_per_file_tooltip")) + pgl.addRow(tr("settings.max_files"), self.attach_files) + pgl.addRow(tr("settings.max_per_file"), self.attach_tokens) + + st = data.get("structure", {}) + _param_section("settings.group.structure") + self.struct_nodes = QSpinBox() + self.struct_nodes.setRange(0, 100000) + self.struct_nodes.setSpecialValueText(tr("settings.unlimited")) + self.struct_nodes.setSuffix(tr("settings.nodes_suffix")) + self.struct_nodes.setValue(max(0, int(st.get("max_nodes", 500)))) + self.struct_nodes.setToolTip(tr("settings.nodes_tooltip")) + self.struct_edges = QSpinBox() + self.struct_edges.setRange(0, 200000) + self.struct_edges.setSpecialValueText(tr("settings.unlimited")) + self.struct_edges.setSuffix(tr("settings.edges_suffix")) + self.struct_edges.setValue(max(0, int(st.get("max_edges", 500)))) + self.struct_edges.setToolTip(tr("settings.edges_tooltip")) + pgl.addRow(tr("settings.max_nodes"), self.struct_nodes) + pgl.addRow(tr("settings.max_edges"), self.struct_edges) + + # Sandbox resource limits (CPU / Memory / Disk I/O) — moved here from + # the Sandbox Security group; still stored under agent_security.*. + _param_section("settings.group.sandbox_limits") + self.sandbox_cpu = QSpinBox() + self.sandbox_cpu.setRange(0, 100_000) + self.sandbox_cpu.setSuffix(" %") + self.sandbox_cpu.setSpecialValueText(tr("settings.sandbox_unlimited")) + self.sandbox_cpu.setValue(int(sec.get("resource_limit_cpu_percent", 0) or 0)) + pgl.addRow(tr("settings.sandbox_cpu_label"), self.sandbox_cpu) + + self.sandbox_memory = QSpinBox() + self.sandbox_memory.setRange(0, 1_000_000) + self.sandbox_memory.setSuffix(" MB") + self.sandbox_memory.setSpecialValueText(tr("settings.sandbox_unlimited")) + self.sandbox_memory.setValue(int(sec.get("resource_limit_memory_mb", 2048) or 2048)) + pgl.addRow(tr("settings.sandbox_memory_label"), self.sandbox_memory) + + self.sandbox_disk = QSpinBox() + self.sandbox_disk.setRange(0, 1_000_000) + self.sandbox_disk.setSuffix(" MB") + self.sandbox_disk.setSpecialValueText(tr("settings.sandbox_unlimited")) + self.sandbox_disk.setValue(int(sec.get("resource_limit_disk_mb", 2048) or 2048)) + pgl.addRow(tr("settings.sandbox_disk_label"), self.sandbox_disk) + + root.addWidget(param_group) + + # ---- Auto Model Routing ------------------------------------------ + routing = self.ctx.config.routing + routing_group = QGroupBox(tr("routing.settings_group")) + rgl = QFormLayout(routing_group) + + self.routing_mode = QComboBox() + for value, key in (("off", "routing.mode_off"), ("auto", "routing.mode_auto"), + ("manual", "routing.mode_manual")): + self.routing_mode.addItem(tr(key), value) + self._select_combo(self.routing_mode, routing.get("switch_mode", "off")) + rgl.addRow(tr("routing.settings_mode"), self.routing_mode) + + self.routing_policy = QComboBox() + for value, key in (("quality", "routing.policy_quality"), ("cost", "routing.policy_cost"), + ("latency", "routing.policy_latency"), ("balanced", "routing.policy_balanced")): + self.routing_policy.addItem(tr(key), value) + self._select_combo(self.routing_policy, routing.get("policy", "balanced")) + rgl.addRow(tr("routing.settings_policy"), self.routing_policy) + + # Min score gain stored as a fraction (0..1); shown as a percentage. + self.routing_min_gain = QSpinBox() + self.routing_min_gain.setRange(0, 100) + self.routing_min_gain.setSuffix(" %") + self.routing_min_gain.setValue(int(round(float(routing.get("min_score_gain", 0.05)) * 100))) + rgl.addRow(tr("routing.settings_min_gain"), self.routing_min_gain) + + self.routing_timeout = QSpinBox() + self.routing_timeout.setRange(5, 600) + self.routing_timeout.setSuffix(" s") + self.routing_timeout.setValue(int(routing.get("confirm_timeout_sec", 60) or 60)) + rgl.addRow(tr("routing.settings_timeout"), self.routing_timeout) + + self.routing_interval = QSpinBox() + self.routing_interval.setRange(0, 720) + self.routing_interval.setSpecialValueText(tr("routing.mode_off")) # 0 = disabled + self.routing_interval.setSuffix(" h") + self.routing_interval.setValue(int(routing.get("reassess_interval_hours", 24) or 0)) + rgl.addRow(tr("routing.settings_interval"), self.routing_interval) + + self.routing_concurrency = QSpinBox() + self.routing_concurrency.setRange(1, 16) + self.routing_concurrency.setValue(int(routing.get("per_provider_concurrency", 2) or 2)) + rgl.addRow(tr("routing.settings_concurrency"), self.routing_concurrency) + + self.routing_judge = QLineEdit(routing.get("judge_model", "")) + rgl.addRow(tr("routing.settings_judge"), self.routing_judge) + + self.routing_reassess_btn = QPushButton(tr("routing.settings_reassess_now")) + self.routing_reassess_btn.clicked.connect(self._routing_reassess_now) + rgl.addRow("", self.routing_reassess_btn) + + rhint = QLabel(tr("routing.settings_hint")) + rhint.setObjectName("hint") + rhint.setWordWrap(True) + rgl.addRow(rhint) + root.addWidget(routing_group) + + note = QLabel(tr("settings.tip")) + note.setObjectName("hint") + root.addWidget(note) + + scroll.setWidget(self._content) + outer.addWidget(scroll, 1) + + buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) + buttons.accepted.connect(self._save) + buttons.rejected.connect(self.reject) + outer.addWidget(buttons) + + from .widgets import guard_wheel + guard_wheel(self) + + screen = QGuiApplication.primaryScreen() + if screen: + avail = screen.availableGeometry() + self.resize(640, min(740, avail.height() - 80)) + self.setMaximumHeight(avail.height()) + + # ---- helpers ----------------------------------------------------- + @staticmethod + def _secret(value: str) -> QLineEdit: + edit = QLineEdit(value) + edit.setEchoMode(QLineEdit.Password) + return edit + + @staticmethod + def _select_combo(combo: QComboBox, value: str) -> None: + idx = combo.findData(value) + if idx >= 0: + combo.setCurrentIndex(idx) + + @staticmethod + def _group(title: str, rows) -> QGroupBox: + box = QGroupBox(title) + form = QFormLayout(box) + for label, widget in rows: + form.addRow(label, widget) + return box + + def _routing_reassess_now(self) -> None: + """Kick off a manual model reassessment in the background.""" + try: + service = self.ctx.routing() + if service.is_reassessing(): + return + self.routing_reassess_btn.setEnabled(False) + self.routing_reassess_btn.setText(tr("routing.reassessing")) + + def _done(result) -> None: + # Re-enable from the (worker) callback; label reflects the count. + self.routing_reassess_btn.setEnabled(True) + self.routing_reassess_btn.setText( + tr("routing.reassess_done", count=len(result or {}))) + + service.reassess_background(on_done=_done) + except Exception: # noqa: BLE001 — a reassess click must never crash Settings + self.routing_reassess_btn.setEnabled(True) + self.routing_reassess_btn.setText(tr("routing.settings_reassess_now")) + + @staticmethod + def _model_combo(value: str) -> QComboBox: + combo = QComboBox() + combo.setEditable(True) + if value: + combo.addItem(value) + combo.setCurrentText(value) + return combo + + def _with_load(self, combo: QComboBox, status: QLabel) -> QWidget: + row = QWidget() + lay = QHBoxLayout(row) + lay.setContentsMargins(0, 0, 0, 0) + lay.addWidget(combo, 1) + btn = QPushButton(tr("settings.load")) + btn.setIcon(icon("download")) + btn.setToolTip(tr("settings.load_tooltip")) + btn.clicked.connect( + lambda: self._load_models(self.provider_combo.currentData(), combo, status)) + lay.addWidget(btn) + test_btn = QPushButton(tr("settings.test_connection")) + test_btn.setIcon(icon("flask")) + test_btn.setToolTip(tr("settings.test_connection_tooltip")) + test_btn.clicked.connect( + lambda: self._test_connection(self.provider_combo.currentData(), status)) + lay.addWidget(test_btn) + return row + + def _stash_provider_fields(self) -> None: + staged = self._prov_staging.setdefault(self._prov_current_key, {}) + staged.update({ + "base_url": self.prov_base.text().strip(), + "api_key": self.prov_key.text(), + "model": self.prov_model.currentText().strip(), + }) + + def _on_provider_edit_changed(self) -> None: + self._stash_provider_fields() + self._prov_current_key = self.provider_combo.currentData() + conf = self._prov_staging.get(self._prov_current_key, {}) + self.prov_base.setText(conf.get("base_url", "")) + self.prov_key.setText(conf.get("api_key", "")) + self.prov_model.clear() + if conf.get("model"): + self.prov_model.addItem(conf["model"]) + self.prov_model.setCurrentText(conf["model"]) + else: + self.prov_model.setCurrentText("") + self.prov_status.setText("") + + def _current_conf(self, provider: str) -> dict: + if provider == self._prov_current_key: + return {"base_url": self.prov_base.text().strip(), "api_key": self.prov_key.text(), + "model": self.prov_model.currentText().strip()} + conf = self._prov_staging.get(provider, {}) + return {"base_url": conf.get("base_url", ""), "api_key": conf.get("api_key", ""), + "model": conf.get("model", "")} + + # ---- MS365 zero-config sign-in ("connect like Claude") --------------- + def _refresh_ms365_status(self) -> None: + from ..core.ms365_auth import current_identity + who = current_identity(self.ctx.config) + if who: + self.ms365_status.setText(tr("settings.ms365_signed_in", who=who)) + self.ms365_signin_btn.setEnabled(False) + self.ms365_signout_btn.setEnabled(True) + else: + self.ms365_status.setText(tr("settings.ms365_signed_out")) + self.ms365_signin_btn.setEnabled(True) + self.ms365_signout_btn.setEnabled(False) + self.ms365_signin_btn.setText(tr("settings.ms365_signin_btn")) + self.ms365_signout_btn.setText(tr("settings.ms365_signout_btn")) + + def _ms365_sign_in(self) -> None: + from ..core.ms365_auth import current_identity, sign_in + self.ms365_signin_btn.setEnabled(False) + self.ms365_status.setText(tr("settings.ms365_signing_in")) + cfg = self.ctx.config + + def job(worker): + # on_code fires (worker thread) with the MSAL device-flow dict — + # marshal it to the UI thread via the worker's event signal. + return sign_in(lambda flow: worker.event.emit({"device_flow": flow}), cfg) + + def on_event(ev: dict) -> None: + if "device_flow" in ev: + self._show_ms365_device_code(ev["device_flow"]) + + def done(_result) -> None: + self._close_ms365_code_dialog() + self.ctx.save() + self._refresh_ms365_status() + QMessageBox.information( + self, tr("settings.ms365_signin_btn"), + tr("settings.ms365_signed_in", who=current_identity(cfg))) + + def failed(err: str) -> None: + self._close_ms365_code_dialog() + self._refresh_ms365_status() + QMessageBox.warning(self, tr("settings.ms365_signin_btn"), err) + + w = AgentWorker(job) + w.event.connect(on_event) + w.finished_ok.connect(done) + w.failed.connect(failed) + self._ms365_workers.append(w) + w.start() + + def _close_ms365_code_dialog(self) -> None: + dlg = getattr(self, "_ms365_code_dialog", None) + if dlg is not None: + dlg.close() + self._ms365_code_dialog = None + + def _show_ms365_device_code(self, flow: dict) -> None: + """Auto-open the sign-in page + show the one-time code in a COPYABLE, + non-modal dialog (so the worker keeps polling and can auto-close it on + success). The code is also copied to the clipboard immediately.""" + import webbrowser + + code = flow.get("user_code", "") + url = flow.get("verification_uri", "https://microsoft.com/devicelogin") + # Auto-copy the code so the user can just paste it. + QGuiApplication.clipboard().setText(code) + # Auto-open the browser to the (code-prefilled, if available) sign-in page. + try: + webbrowser.open(flow.get("verification_uri_complete") or url) + except Exception: # noqa: BLE001 — a headless box just shows the link to click + pass + + self._close_ms365_code_dialog() + dlg = QDialog(self) + dlg.setWindowTitle(tr("settings.ms365_signin_btn")) + dlg.setMinimumWidth(420) + lay = QVBoxLayout(dlg) + info = QLabel(tr("settings.ms365_code_hint", url=url)) + info.setWordWrap(True) + info.setTextInteractionFlags(Qt.TextSelectableByMouse | Qt.TextBrowserInteraction) + info.setOpenExternalLinks(True) + lay.addWidget(info) + + code_row = QHBoxLayout() + code_edit = QLineEdit(code) + code_edit.setReadOnly(True) + f = code_edit.font() + f.setPointSize(f.pointSize() + 4) + f.setBold(True) + code_edit.setFont(f) + code_edit.setCursorPosition(0) + copy_btn = QPushButton(tr("settings.ms365_copy_code")) + copy_btn.setIcon(icon("document")) + copy_btn.clicked.connect(lambda: QGuiApplication.clipboard().setText(code)) + open_btn = QPushButton(tr("settings.ms365_open_link")) + open_btn.setIcon(icon("link")) + open_btn.clicked.connect(lambda: webbrowser.open(flow.get("verification_uri_complete") or url)) + code_row.addWidget(code_edit, 1) + code_row.addWidget(copy_btn) + code_row.addWidget(open_btn) + lay.addLayout(code_row) + + buttons = QDialogButtonBox(QDialogButtonBox.Close) + buttons.rejected.connect(dlg.reject) + lay.addWidget(buttons) + + self._ms365_code_dialog = dlg + dlg.show() # non-modal — sign-in polling continues; done() closes it + + def _ms365_sign_out(self) -> None: + from ..core.ms365_auth import sign_out_default + sign_out_default(self.ctx.config) + self._refresh_ms365_status() + + def _load_models(self, provider: str, combo: QComboBox, status: QLabel) -> None: + conf = self._current_conf(provider) + + def job(worker): + from ..providers import build_provider + prov = build_provider(provider, conf) + models = prov.list_models() + return {"models": models, "error": getattr(prov, "last_error", "")} + + def done(result): + models = result.get("models") or [] + current = combo.currentText().strip() + combo.clear() + if current: + combo.addItem(current) + for m in models: + if m != current: + combo.addItem(m) + combo.setCurrentText(current) + error = result.get("error", "") + if models: + status.setText(tr("settings.loaded_models", n=len(models), + provider=PROVIDER_LABELS.get(provider, provider))) + else: + status.setText(tr("settings.load_models_error", err=error or + tr("settings.load_models_error_unknown"))) + + w = AgentWorker(job) + w.finished_ok.connect(done) + w.failed.connect(lambda e: status.setText(tr("settings.load_failed", err=e))) + self._load_workers.append(w) + status.setText(tr("settings.loading_models")) + w.start() + + def _test_connection(self, provider: str, status: QLabel) -> None: + conf = self._current_conf(provider) + + def job(worker): + from ..providers import build_provider + ok, message = build_provider(provider, conf).test_connection() + return {"ok": ok, "message": message} + + def done(result): + ok = result.get("ok") + status.setText(result.get("message", "")) + status.setStyleSheet("color: #090;" if ok else "color: #c00;") + + def failed(e): + status.setText(str(e)) + status.setStyleSheet("color: #c00;") + + w = AgentWorker(job) + w.finished_ok.connect(done) + w.failed.connect(failed) + self._load_workers.append(w) + status.setText(tr("settings.testing_connection")) + w.start() + + def _sandbox_unlock(self) -> None: + pw = self.sandbox_pw_edit.text() + if pw == self._sandbox_pw: + self._sandbox_unlocked = True + self.sandbox_locked_status.setText("Unlocked") + self.sandbox_locked_status.set_icon("unlock", "#090") + self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;") + # Enable all sandbox widgets + for w in self._sandbox_widgets: + w.setEnabled(True) + QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.") + else: + QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.") + + def _save(self) -> None: + data = self.ctx.config.data + data["active_provider"] = self.provider_combo.currentData() + data["language"] = self.language_combo.currentData() + + self._stash_provider_fields() + for key, staged in self._prov_staging.items(): + data["providers"].setdefault(key, {}).update({ + "base_url": staged.get("base_url", ""), + "api_key": staged.get("api_key", ""), + "model": staged.get("model", ""), + }) + + # NOTE: allow_url_fetch is managed in Monitoring → Tools → Tool now + # (persisted there directly), so it is intentionally not written here. + data.setdefault("agent_security", {}).update({ + "enabled": self.sec_enabled.isChecked(), + "cowork_confirm_commands": self.sandbox_confirm.isChecked(), + "block_network": self.sandbox_block_network.isChecked(), + "command_ai_check": self.ai_check.isChecked(), + "command_whitelist": [], + "resource_limit_cpu_percent": self.sandbox_cpu.value(), + "resource_limit_memory_mb": self.sandbox_memory.value(), + "resource_limit_disk_mb": self.sandbox_disk.value(), + }) + att = data.setdefault("attachments", {}) + att["max_tokens"] = self.attach_tokens.value() * 1000 + att["max_files"] = self.attach_files.value() + st = data.setdefault("structure", {}) + st["max_nodes"] = self.struct_nodes.value() + st["max_edges"] = self.struct_edges.value() + tray = data.setdefault("tray", {}) + tray["minimize_on_close"] = self.tray_chk.isChecked() + tray["notify_on_done"] = self.notify_chk.isChecked() + + r = data.setdefault("routing", {}) + r["switch_mode"] = self.routing_mode.currentData() + r["policy"] = self.routing_policy.currentData() + r["min_score_gain"] = self.routing_min_gain.value() / 100.0 + r["confirm_timeout_sec"] = self.routing_timeout.value() + r["reassess_interval_hours"] = self.routing_interval.value() + r["per_provider_concurrency"] = self.routing_concurrency.value() + r["judge_model"] = self.routing_judge.text().strip() + + self.ctx.save() + + # Force-reload config so all parts of the app pick up the new settings immediately + self.ctx.config._data = None # invalidate cache + self.ctx.config._agent_security = None + + self.accept() \ No newline at end of file diff --git a/ui/spline_chart.py b/ui/spline_chart.py index 60e6d8e..6f0ce4a 100644 --- a/ui/spline_chart.py +++ b/ui/spline_chart.py @@ -13,7 +13,7 @@ from PySide6.QtCore import QPointF, Qt from PySide6.QtGui import QBrush, QColor, QLinearGradient, QPainter, QPainterPath, QPen from PySide6.QtWidgets import QWidget -from ..theme import ACCENT +from ..theme import current_palette def _endpoint_label_rect(point_x: float, point_y: float, text_width: float, @@ -75,17 +75,13 @@ class SplineChart(QWidget): self._refs = list(refs or []) self.update() - def _dark(self) -> bool: - from .chat_view import _app_theme - return _app_theme() == "dark" - def paintEvent(self, _e): # noqa: N802 p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) - dark = self._dark() - grid = QColor("#1A2D4A" if dark else "#C7DEEE") - text = QColor("#8FB2D4" if dark else "#5C7A94") - accent = QColor(ACCENT) + tok = current_palette() + grid = QColor(tok.chart_grid) + text = QColor(tok.chart_label) + accent = QColor(tok.accent) w, h = self.width(), self.height() pts = self._points diff --git a/ui/structure_graph_view.py b/ui/structure_graph_view.py index d361134..03f1b3c 100644 --- a/ui/structure_graph_view.py +++ b/ui/structure_graph_view.py @@ -1,993 +1,998 @@ -"""Structure (RAG) tab — knowledge graph of code / document structure. - -Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates -when idle and opens a node's storage folder on click. If WebEngine isn't -available (e.g. the standalone .exe), a native draggable QGraphicsView is the -in-app fallback. The graph auto-updates when the Code agent produces output, -and an Agent box on the right answers questions over the graph (Graph-RAG). -""" -from __future__ import annotations - -import math -import re -import sys -from pathlib import Path - -from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot -from PySide6.QtGui import QBrush, QColor, QFont, QPen -from PySide6.QtWidgets import ( - QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem, - QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, - QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, - QTextBrowser, QVBoxLayout, QWidget, -) - -def _frozen_onefile() -> bool: - """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a - temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process - can't run — creating a QWebEngineView hard-crashes the app (reported as - "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the - ``_internal`` folder right next to the exe, where WebEngine works fine, so - it keeps the full embedded D3 view.""" - if not getattr(sys, "frozen", False): - return False - meipass = getattr(sys, "_MEIPASS", "") - if not meipass: - return False - try: - return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent - except OSError: # can't tell → play safe: use the native fallback - return True - - -try: # WebEngine + WebChannel are optional PySide6 add-ons - from PySide6.QtWebEngineWidgets import QWebEngineView - from PySide6.QtWebChannel import QWebChannel - _HAS_WEB = not _frozen_onefile() -except Exception: # pragma: no cover - _HAS_WEB = False - -from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS -from ..core.worker import AgentWorker -from ..i18n import on_language_changed, tr -from ..state import AppContext -from .icons import collapse_right_icon, icon -from .osutil import open_folder, open_location -from .widgets import CollapseStrip - -try: - from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available -except Exception: - pass - - -class _Bridge(QObject): - """Exposed to the D3 page so a Shift+click on a node can open its - storage folder/link (local path or URL — see osutil.open_location).""" - - @Slot(str) - def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name - if path: - open_location(path) - - -class _Edge(QGraphicsLineItem): - def __init__(self, a: "_Node", b: "_Node", type_: str = ""): - super().__init__() - self.a, self.b = a, b - self.type = type_ - # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), - # so the graph shows what each connection MEANS — falling back to the - # source node's tint for any untyped edge. - color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() - if not color.isValid(): - color = a.brush().color().lighter(130) - self._color = color - self.setPen(QPen(color, 1.4)) - self.setZValue(-1) - # A small label naming the relationship, shown at the edge midpoint. - self._label = None - if type_: - self._label = QGraphicsSimpleTextItem(type_, self) - self._label.setBrush(QBrush(color.lighter(140))) - f = QFont() - f.setPointSize(7) - self._label.setFont(f) - self._label.setZValue(0) - a.edges.append(self) - b.edges.append(self) - self.adjust() - - def adjust(self) -> None: - pa, pb = self.a.scenePos(), self.b.scenePos() - self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) - if self._label is not None: - br = self._label.boundingRect() - self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, - (pa.y() + pb.y()) / 2 - br.height() / 2) - - -class _Node(QGraphicsEllipseItem): - def __init__(self, data, radius: int): - super().__init__(-radius, -radius, 2 * radius, 2 * radius) - self.data = data - self.edges = [] - color = QColor(NODE_KIND_COLORS.get(data.kind, "#888888")) - self.setBrush(QBrush(color)) - self.setPen(QPen(color.darker(160), 1.5)) - self.setFlags( - QGraphicsEllipseItem.ItemIsMovable - | QGraphicsEllipseItem.ItemIsSelectable - | QGraphicsEllipseItem.ItemSendsGeometryChanges - ) - self.setZValue(1) - label = QGraphicsSimpleTextItem(data.label, self) - label.setBrush(QBrush(QColor("#e6e6e6"))) - label.setPos(radius + 3, -8) - - def itemChange(self, change, value): # noqa: N802 - if change == QGraphicsEllipseItem.ItemPositionHasChanged: - for edge in self.edges: - edge.adjust() - return super().itemChange(change, value) - - -class _GraphView(QGraphicsView): - def __init__(self, scene): - super().__init__(scene) - self.setDragMode(QGraphicsView.NoDrag) - self._panning = False - self._pan_start = QPointF() - - def wheelEvent(self, e): # noqa: N802 - self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, - 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) - - def mousePressEvent(self, e): # noqa: N802 - if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: - self._panning = True - self._pan_start = e.position() - self.setCursor(Qt.ClosedHandCursor) - e.accept() - return - super().mousePressEvent(e) - - def mouseMoveEvent(self, e): # noqa: N802 - if self._panning: - delta = e.position() - self._pan_start - self._pan_start = e.position() - self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) - self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) - e.accept() - return - super().mouseMoveEvent(e) - - def mouseReleaseEvent(self, e): # noqa: N802 - if self._panning: - self._panning = False - self.setCursor(Qt.ArrowCursor) - e.accept() - return - super().mouseReleaseEvent(e) - - def mouseDoubleClickEvent(self, e): # noqa: N802 - """Double-click or Ctrl+click on a node opens its storage folder.""" - item = self.itemAt(e.pos()) - if isinstance(item, _Node) and getattr(item.data, "path", ""): - open_folder(item.data.path) - e.accept() - return - super().mouseDoubleClickEvent(e) - - -class StructureGraphView(QWidget): - status_message = Signal(str) - - def __init__(self, ctx: AppContext): - super().__init__() - self.ctx = ctx - self._worker: AgentWorker | None = None - self._node_items: list[_Node] = [] - self._edge_items: list[_Edge] = [] - self._centroid = QPointF(0, 0) - self._link = 120 - self._graph = None - self._needs_scan = False - self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) - self._ask_worker: AgentWorker | None = None - self._answer = "" - self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows - # TEMPORARY extracted file content for Q&A (real content, not just the - # graph structure). Kept only while this tab is shown — cleared on leaving - # the tab or switching project/root (see _clear_extracts / hideEvent). - self._extract_cache: dict = {} # path -> extracted text - self._extract_dir = None # temp folder for md/json dumps - self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox - - self._rescan_timer = QTimer(self) - self._rescan_timer.setSingleShot(True) - self._rescan_timer.setInterval(1500) - self._rescan_timer.timeout.connect(self._scan) - - root = QVBoxLayout(self) - - bar = QHBoxLayout() - self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn = QPushButton() - self._pick_btn.setIcon(icon("folder")) - self._pick_btn.setObjectName("primary") - self._pick_btn.clicked.connect(self._pick) - self.project_combo = QComboBox() - self.project_combo.currentIndexChanged.connect(self._on_project_changed) - self._scan_btn = QPushButton() - self._scan_btn.setIcon(icon("search")) - self._scan_btn.setObjectName("primary") - self._scan_btn.clicked.connect(self._scan) - bar.addWidget(self.path_edit, 1) - bar.addWidget(self._pick_btn) - bar.addWidget(self.project_combo) - bar.addWidget(self._scan_btn) - root.addLayout(bar) - self._refresh_project_combo() - - # Toolbar: messages toggle + export - bar2 = QHBoxLayout() - bar2.addStretch(1) - self._msgs_toggle_btn = QPushButton() - self._msgs_toggle_btn.setIcon(icon("message")) - self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip")) - self._msgs_toggle_btn.clicked.connect(self._toggle_messages) - bar2.addWidget(self._msgs_toggle_btn) - - self._export_btn = QPushButton() - self._export_btn.setIcon(icon("upload")) - self._export_btn.setObjectName("primary") - self._export_btn.clicked.connect(self._export) - bar2.addWidget(self._export_btn) - - root.addLayout(bar2) - - split = QSplitter(Qt.Horizontal) - self.scene = QGraphicsScene() - self.scene.setBackgroundBrush(QColor("#0D1F35")) # deep ocean dark bg - self.scene.selectionChanged.connect(self._on_selection) - self.view = _GraphView(self.scene) - - self._stack = QStackedWidget() - self._stack.addWidget(self.view) - # A "Messages" view: all conversation messages grouped BY DAY, shown as - # JSON — a plain tree switched in via setCurrentWidget (never touches the - # D3/WebEngine graph). Populated from the (project-scoped) history store. - from PySide6.QtWidgets import QTreeWidget - self._msgs_view = QTreeWidget() - self._msgs_view.setHeaderHidden(True) - self._msgs_view.itemClicked.connect(self._show_msg_json) - self._stack.addWidget(self._msgs_view) - self.web = None - self._bridge = None - self._channel = None - - # The legend + Show-relationship control live INSIDE the D3 graph - # template now (assets/graph_template.html) — the graph column is just - # the stack (native view / D3 web / messages). - split.addWidget(self._stack) - - # Right-side agent panel (GraphRAG Q&A) - right = QWidget() - rl = QVBoxLayout(right) - rl.setContentsMargins(0, 0, 0, 0) - - # Agent panel header with collapse button - ag_hdr = QHBoxLayout() - self._ag_collapse = QPushButton() - self._ag_collapse.setIcon(collapse_right_icon()) - self._ag_collapse.setFixedWidth(28) - self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True)) - self._ag_label = QLabel() - ag_hdr.addWidget(self._ag_collapse) - ag_hdr.addWidget(self._ag_label, 1) - rl.addLayout(ag_hdr) - - # Ask row - ask_row = QHBoxLayout() - self.ask_edit = QLineEdit() - self.ask_edit.returnPressed.connect(self._ask) - self._ask_btn = QPushButton() - self._ask_btn.setIcon(icon("chat")) - self._ask_btn.setObjectName("primary") - self._ask_btn.clicked.connect(self._ask) - ask_row.addWidget(self.ask_edit, 1) - ask_row.addWidget(self._ask_btn) - rl.addLayout(ask_row) - - # Detail browser - self.detail = QTextBrowser() - self.detail.setReadOnly(True) - self.detail.setOpenLinks(False) - self.detail.anchorClicked.connect(self._on_detail_link) - rl.addWidget(self.detail, 1) - - self._agent_panel = right - - self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") - self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False)) - self._agent_strip.setVisible(False) - self._agent_pane = QWidget() - apl = QHBoxLayout(self._agent_pane) - apl.setContentsMargins(0, 0, 0, 0) - apl.setSpacing(0) - apl.addWidget(self._agent_strip) - apl.addWidget(right, 1) - - self._split = split - split.addWidget(self._agent_pane) - split.setChildrenCollapsible(False) - split.setSizes([840, 320]) - root.addWidget(split, 1) - on_language_changed(self._retranslate) - - def _retranslate(self) -> None: - self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) - self._pick_btn.setText(tr("structure.browse")) - self._scan_btn.setText(tr("structure.scan")) - self._export_btn.setText(tr("structure.export_png")) - showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None) - self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn")) - self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) - self._ag_label.setText(tr("structure.agent_header")) - self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) - self._ask_btn.setText(tr("structure.ask")) - if self._detail_mode == "idle": - self.detail.setPlaceholderText(tr("structure.detail_placeholder")) - self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) - self.project_combo.setToolTip(tr("structure.project_tooltip")) - self._refresh_project_combo() - - # ---- project sandbox lock ----------------------------------------- - def _refresh_project_combo(self) -> None: - from ..core.projects import list_projects - - keep = self._active_project_id - self.project_combo.blockSignals(True) - self.project_combo.clear() - self.project_combo.addItem(tr("structure.project_none"), "") - row_to_select = 0 - for i, p in enumerate(list_projects(), start=1): - self.project_combo.addItem(p.name, p.project_id) - if p.project_id == keep: - row_to_select = i - self.project_combo.setCurrentIndex(row_to_select) - self.project_combo.blockSignals(False) - - def set_project(self, project_id: str) -> None: - pid = project_id or "" - self._refresh_project_combo() - target = self.project_combo.findData(pid) - if target < 0: - target = 0 - if self.project_combo.currentIndex() == target: - self._on_project_changed(target) - else: - self.project_combo.setCurrentIndex(target) - - def _on_project_changed(self, _idx: int) -> None: - from ..core.projects import load_project - - pid = self.project_combo.currentData() or "" - project_changed = pid != self._active_project_id - if project_changed: - self._clear_extracts() # different workspace → drop temp extraction - self._active_project_id = pid - locked = bool(pid) - self.path_edit.setReadOnly(locked) - # Also disable the folder-pick button — otherwise the scan path is only - # "locked" against typing, but the picker could still repoint it outside - # the selected project's sandbox, breaking GraphRAG scope isolation. - self._pick_btn.setEnabled(not locked) - if locked: - project = load_project(pid) - if project is not None: - self.path_edit.setText(str(project.workspace_dir())) - if project_changed: - self._needs_scan = True - if self.web is not None: - self._needs_scan = False - self._scan() - - # ---- helpers ----------------------------------------------------- - def _pick(self) -> None: - chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) - if chosen: - self.path_edit.setText(chosen) - - def schedule_rescan(self, path: str = "") -> None: - if self._graph is None: - self._needs_scan = True - return - self._rescan_timer.start() - - # ---- Messages (by day, as JSON) -------------------------------------- - def _toggle_messages(self) -> None: - """Switch between the knowledge graph and the Messages-by-day view.""" - showing = self._stack.currentWidget() is self._msgs_view - if showing: - self._stack.setCurrentWidget(self.web if self.web is not None else self.view) - else: - self._reload_messages() - self._stack.setCurrentWidget(self._msgs_view) - self._msgs_toggle_btn.setText( - tr("structure.graph_btn") if not showing else tr("structure.msgs_btn")) - - def _reload_messages(self) -> None: - """Build the tree: day → conversation. Click a conversation to see its - messages as JSON. Scoped to the current project (its history folder).""" - from collections import OrderedDict - - from PySide6.QtCore import Qt - from PySide6.QtWidgets import QTreeWidgetItem - - from ..core.history import list_conversations - self._msgs_view.clear() - pid = self._active_project_id or "" - by_day: "OrderedDict[str, list]" = OrderedDict() - try: - convs = list_conversations(self.ctx.config.history_dir()) - except Exception: # noqa: BLE001 - convs = [] - for conv in convs: - if pid and conv.get("project_id", "default") != pid: - continue - day = (conv.get("created") or "")[:10] or "—" - by_day.setdefault(day, []).append(conv) - if not by_day: - self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) - return - for day in sorted(by_day, reverse=True): - convs_d = by_day[day] - day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) - for conv in convs_d: - it = QTreeWidgetItem([conv.get("title", "(untitled)")]) - it.setData(0, Qt.UserRole, str(conv.get("path", ""))) - day_item.addChild(it) - self._msgs_view.addTopLevelItem(day_item) - day_item.setExpanded(True) - - def _show_msg_json(self, item, _col: int = 0) -> None: - import html - import json - - from PySide6.QtCore import Qt - - from ..core.history import load_conversation - path = item.data(0, Qt.UserRole) - if not path: - return - try: - conv = load_conversation(path) - payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), - "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), - "messages": conv.get("messages", [])} - text = json.dumps(payload, ensure_ascii=False, indent=2) - except Exception as exc: # noqa: BLE001 - text = f"(could not read: {exc})" - self.detail.setHtml( - f'
    {html.escape(text)}
    ') - - def _ensure_web(self) -> None: - if self.web is not None or not _HAS_WEB: - return - self.web = QWebEngineView() - self._bridge = _Bridge() - self._channel = QWebChannel() - self._channel.registerObject("py", self._bridge) - self.web.page().setWebChannel(self._channel) - self._stack.addWidget(self.web) - self._stack.setCurrentWidget(self.web) - if self._graph is not None: - self._render_d3() - - def auto_scan_and_fit(self) -> None: - self._ensure_web() - if not self.path_edit.text().strip(): - return - if getattr(self, "_worker", None) is not None and self._worker.isRunning(): - self._fit() - self._preserve_answer() - return - if self._graph is not None and not self._needs_scan: - self._fit() - self._preserve_answer() - return - self._needs_scan = False - self._scan() - - # ---- scan -------------------------------------------------------- - def _scan(self) -> None: - path = self.path_edit.text().strip() or str(Path.cwd()) - mode = "files" # default: scan all files (filter removed) - use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) - cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") - st = self.ctx.config.structure - max_nodes = int(st.get("max_nodes", 500) or 0) - max_edges = int(st.get("max_edges", 500) or 0) - self._scan_seq += 1 - seq = self._scan_seq - self.status_message.emit(tr("structure.scanning")) - - def job(worker: AgentWorker): - from ..core.structure_graph import ( - build_from_codebase_memory, build_from_directory, force_layout, - ) - if use_cmem: - from ..core.codebase_memory import CodebaseMemory - mem = CodebaseMemory(cmem_bin) - graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) - if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) - else: - graph = build_from_directory(path, mode, max_nodes, max_edges) - pos = force_layout(graph) - return {"graph": graph, "pos": pos, "seq": seq} - - w = AgentWorker(job) - w.finished_ok.connect(self._render) - w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) - self._worker = w - w.start() - - def _render(self, result: dict) -> None: - if result.get("seq") is not None and result["seq"] != self._scan_seq: - return - graph = result.get("graph") - pos = result.get("pos", {}) - if graph is None: - return - self._graph = graph - - self.scene.clear() - self.scene.setBackgroundBrush(QColor("#0D1F35")) # restore deep ocean bg after clear - self._node_items = [] - self._edge_items = [] - degree = {n.id: 0 for n in graph.nodes} - for e in graph.edges: - if e.source in degree: - degree[e.source] += 1 - if e.target in degree: - degree[e.target] += 1 - items = {} - sx = sy = 0.0 - for node in graph.nodes: - radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) - item = _Node(node, radius) - x, y = pos.get(node.id, (0, 0)) - item.setPos(x, y) - self.scene.addItem(item) - items[node.id] = item - self._node_items.append(item) - sx += x - sy += y - for edge in graph.edges: - a, b = items.get(edge.source), items.get(edge.target) - if a and b: - e = _Edge(a, b, getattr(edge, "type", "")) - self.scene.addItem(e) - self._edge_items.append(e) - n = max(1, len(self._node_items)) - self._centroid = QPointF(sx / n, sy / n) - self._fit() - - if self.web is not None: - self._render_d3() - - note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" - self.status_message.emit(tr( - "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) - self._preserve_answer() - - def _render_d3(self) -> None: - if self.web is None or self._graph is None: - return - from ..core.d3_graph import build_html - try: - self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) - except Exception as exc: - self.status_message.emit(f"D3 view error: {exc}") - - # ---- native interactions ---------------------------------------- - def _on_selection(self) -> None: - for item in self.scene.selectedItems(): - if isinstance(item, _Node): - d = item.data - self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}") - self._detail_mode = "node" - return - - def _preserve_answer(self) -> None: - if self._detail_mode == "answer" and self._answer.strip(): - self._render_answer() - - def _set_agent_collapsed(self, collapsed: bool) -> None: - strip_w = CollapseStrip.WIDTH + 2 - self._agent_panel.setVisible(not collapsed) - self._agent_strip.setVisible(collapsed) - if collapsed: - self._agent_pane.setMaximumWidth(strip_w) - sizes = self._split.sizes() - if len(sizes) == 2: - self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w]) - else: - self._agent_pane.setMaximumWidth(16777215) - self._split.setSizes([840, 320]) - - def _fit(self) -> None: - if self.web is not None and self._stack.currentWidget() is self.web: - self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") - return - rect = self.scene.itemsBoundingRect() - if not rect.isNull(): - self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) - - def _export(self) -> None: - path, _ = QFileDialog.getSaveFileName( - self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") - if not path: - return - showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) - if showing_d3: - self._export_d3_png(path) - else: - self._export_widget_grab(path) - - def _export_d3_png(self, path: str) -> None: - def on_result(data_url) -> None: - if not isinstance(data_url, str) or "," not in data_url: - self._export_widget_grab(path) - return - import base64 - try: - with open(path, "wb") as f: - f.write(base64.b64decode(data_url.split(",", 1)[1])) - self.status_message.emit(tr("structure.export_done", path=path)) - except (OSError, ValueError) as exc: - self.status_message.emit(tr("structure.export_failed", err=str(exc))) - self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) - - def _export_widget_grab(self, path: str) -> None: - ok = self._stack.currentWidget().grab().save(path, "PNG") - if ok: - self.status_message.emit(tr("structure.export_done", path=path)) - else: - self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) - - # ---- agent Q&A over the graph ----------------------------------- - @staticmethod - def _graph_context(graph) -> str: - from collections import defaultdict - by_kind = defaultdict(list) - for n in graph.nodes: - by_kind[n.kind].append(n.label) - lines = [] - for kind in ("file", "class", "function", "method", "module", "section"): - items = by_kind.get(kind, []) - if items: - lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) - id2label = {n.id: n.label for n in graph.nodes} - rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" - for e in graph.edges[:140]] - if rels: - lines.append("Relationships (sample):\n" + "\n".join(rels)) - return "\n".join(lines)[:7000] - - def _matched_sources(self, text: str): - if self._graph is None or not text: - return [] - found: dict[str, tuple[str, str, str]] = {} - for n in self._graph.nodes: - if not n.path: - continue - label = n.label.rstrip("()") - if len(label) < 3: - continue - if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text): - found[n.path] = (n.kind, n.label, n.detail or n.path) - return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12] - - def _linkify_files(self, text: str, sources) -> str: - """Turn file/entity NAMES mentioned in the answer into clickable links that - open the file — so the user can click a name in the answer to view it.""" - for path, (kind, label, rel) in sources: - href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) - tokens = [] - base = Path(path).name - if base and len(base) >= 3: - tokens.append(base) - lab = (label or "").rstrip("()").strip() - if lab and lab != base and len(lab) >= 3: - tokens.append(lab) - for tok in tokens: - esc = re.escape(tok) - # `tok` (code span) → keep the code style but make it a link - text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text) - # bare tok, not already inside a link / path / code span - text = re.sub(rf"(? None: - text = self._answer - sources = self._matched_sources(text) - if sources: - # 1) Make the file/entity names IN THE ANSWER clickable (open on click). - text = self._linkify_files(text, sources) - # 2) Append a clickable "Related sources" section listing each file. - lines = [text, "", "---", f"**{tr('structure.related_sources')}**"] - for path, (kind, label, rel) in sources: - href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) - # kind badge for context (file/function/section/json_key) - kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else "" - lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`") - text = "\n".join(lines) - self.detail.setMarkdown(text) - - def _on_detail_link(self, url: QUrl) -> None: - if url.isLocalFile(): - p = url.toLocalFile() - # Open the FILE itself for viewing (fall back to its folder for a dir). - if Path(p).is_file(): - open_location(p) - else: - open_folder(p) - - def _ask(self) -> None: - question = self.ask_edit.text().strip() - if not question: - return - from ..core.skills import parse_skill_command - skill_prefix, question, info = parse_skill_command(question) - if info is not None: - self.detail.setMarkdown(info) - self._detail_mode = "answer" - self.ask_edit.clear() - return - if self._graph is None: - self.status_message.emit(tr("structure.scan_first")) - return - context = self._graph_context(self._graph) - # Real file CONTENT to answer from (extracted temporarily in the worker): - file_paths = self._candidate_file_paths() - extract_cache = dict(self._extract_cache) - extract_dir = str(self._extract_tmp_dir()) - self._answer = "" - self._detail_mode = "answer" - self.detail.setPlainText("…") - self.ask_edit.clear() - - active_project_id = self._active_project_id - - # Collect selected node context for auto-filtering - selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - selected_context = "" - if selected_nodes: - node_lines = [] - for nd in selected_nodes: - node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})") - if nd.detail: - node_lines.append(f" detail: {nd.detail}") - # Also gather connected nodes - connected_ids = set() - for nd in selected_nodes: - for edge in self._graph.edges: - if edge.source == nd.id: - connected_ids.add(edge.target) - elif edge.target == nd.id: - connected_ids.add(edge.source) - connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids] - if connected_nodes: - node_lines.append("\nConnected nodes:") - for cn in connected_nodes: - node_lines.append(f"- {cn.label} (kind: {cn.kind})") - selected_context = "\n".join(node_lines) - - def job(worker: AgentWorker): - provider = self.ctx.build_active_provider() - system = ("You answer questions about a code/document knowledge graph. Use the provided " - "graph context AND the extracted file contents to retrieve, synthesize and " - "explain the answer. Be concise. Answer ONLY from what is provided (graph " - "context + extracted contents) — never invent files, functions, or facts that " - "aren't in it.\n\n" - "EACH answer MUST include source citations so the user can verify where " - "information came from. For every factual claim, file reference, or code " - "element you mention, add a citation using this format:\n\n" - " [source: filename.ext, line/section: XXX]\n\n" - "Rules for citations:\n" - " 1. Cite the EXACT file path from the graph context (use the path field).\n" - " 2. For Python files: cite the function/class name and approximate line " - " if available, or the module name.\n" - " 3. For document files (.md, .txt): cite the section heading.\n" - " 4. For JSON files: cite the key path (e.g. settings > database > host).\n" - " 5. Place citations inline after the relevant sentence or fact.\n" - " 6. At the end of your answer, add a '---' separator followed by a " - " numbered **Sources cited:** section listing each unique source with " - " its full path so the user can click to open it.\n\n" - "Example citation format in text:\n" - " The `process_data()` function handles CSV parsing " - "[source: src/utils/parser.py, function: process_data].\n\n" - "Example end-of-answer source list:\n" - " ---\n" - " **Sources cited:**\n" - " 1. `src/utils/parser.py` — process_data function\n" - " 2. `docs/api.md` — Section: Authentication\n") - if skill_prefix: - system += "\n\nFollow this skill:\n" + skill_prefix - if active_project_id: - from ..core.projects import load_project, project_context_text - proj_ctx = project_context_text(load_project(active_project_id)) - if proj_ctx: - system += "\n\n" + proj_ctx - user_content = f"Graph context:\n{context}" - if selected_context: - user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}" - # Auto-extract the actual file contents (temporary) so the answer is - # synthesized from real content, not just the graph structure. - content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir) - if content_block: - user_content += ("\n\nExtracted file contents (read these to answer about file " - "details/data; cite the file path):\n" + content_block) - user_content += f"\n\nQuestion: {question}" - messages = [ - {"role": "system", "content": system}, - {"role": "user", "content": user_content}, - ] - from ..core import agent_roles, audit_log - ok = True - try: - provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}), - cancel=worker.is_cancelled) - except Exception: - ok = False - raise - finally: - audit_log.record("tool_call", "graphrag_ask", ok, question[:500], - agent_role=agent_roles.KNOWLEDGE) - return {"extracted": new_cache} - - w = AgentWorker(job) - w.event.connect(self._on_ask_event) - w.finished_ok.connect(self._on_ask_done) - w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}")) - self._ask_worker = w - w.start() - - def _on_ask_event(self, ev: dict) -> None: - if ev.get("type") == "text": - if self._answer == "": - self.detail.clear() - self._answer += ev.get("delta", "") - self.detail.setPlainText(self._answer) - - def _on_ask_done(self, result: dict) -> None: - # Keep the (temporary) extracted content so repeated questions reuse it - # without re-extracting — dropped when leaving the tab (_clear_extracts). - if isinstance(result, dict): - self._extract_cache.update(result.get("extracted", {}) or {}) - self._render_answer() - - # ---- temporary file-content extraction for Q&A ------------------------ - def _candidate_file_paths(self) -> list: - """File paths to read for a question: the SELECTED file nodes if any, else - every file node in the graph (capped downstream).""" - from pathlib import Path as _P - if self._graph is None: - return [] - sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] - nodes = sel or list(self._graph.nodes) - out, seen = [], set() - for nd in nodes: - p = (getattr(nd, "path", "") or "").strip() - if p and p not in seen and _P(p).is_file(): - seen.add(p) - out.append(p) - return out - - def _extract_tmp_dir(self): - from pathlib import Path as _P - if self._extract_dir is None: - import tempfile - from ..config import CONFIG_DIR - base = CONFIG_DIR / "tmp" / "graphrag_extract" - base.mkdir(parents=True, exist_ok=True) - self._extract_dir = _P(tempfile.mkdtemp(dir=str(base))) - return self._extract_dir - - def _clear_extracts(self) -> None: - """Discard the temporary extracted content (on leaving the tab / switching - project). The extraction is a scratch aid, never persisted.""" - self._extract_cache = {} - d, self._extract_dir = self._extract_dir, None - if d is not None: - import shutil - shutil.rmtree(d, ignore_errors=True) - - def hideEvent(self, e): # noqa: N802 - # Leaving the GraphRAG tab → drop the temporary extracted info. - self._clear_extracts() - super().hideEvent(e) - - - - - -# -------------------------------------------------------------------------- -# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker) -# -------------------------------------------------------------------------- -def _pdf_to_markdown(pdf_path, out_dir) -> str | None: - """Convert a PDF to Markdown with opendataloader-pdf when available (richer - structure than a plain text dump). Best-effort — returns None if the package - isn't installed or the call fails, so the caller falls back to doc_extract.""" - from pathlib import Path as _P - try: - import opendataloader_pdf # optional; auto-installed elsewhere if present - except Exception: # noqa: BLE001 - try: - from ..core.deps import ensure_module - if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: - return None - import opendataloader_pdf # noqa: F811 - except Exception: # noqa: BLE001 - return None - out = _P(out_dir) - out.mkdir(parents=True, exist_ok=True) - for call in ( - lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), - generate_markdown=True), - lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), - lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), - ): - try: - call() - break - except TypeError: - continue - except Exception: # noqa: BLE001 - return None - mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) - for md in mds: - try: - return md.read_text(encoding="utf-8", errors="replace") - except OSError: - continue - return None - - -def _extract_file_contents(paths, cache: dict, tmp_dir, - max_files: int = 15, max_total: int = 120_000): - """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when - available, else doc_extract for office/pdf/text). Returns ``(block, cache)`` - — ``block`` is the concatenated content for the prompt (bounded), ``cache`` - maps path→text for reuse. Never raises.""" - from pathlib import Path as _P - from ..core import doc_extract - cache = dict(cache or {}) - parts, total = [], 0 - for p in paths[:max_files]: - if total >= max_total: - break - text = cache.get(p) - if text is None: - try: - if _P(p).suffix.lower() == ".pdf": - text = _pdf_to_markdown(p, tmp_dir) - if not text: - text, _n = doc_extract.extract_text(p) - else: - text, _n = doc_extract.extract_text(p) - except Exception: # noqa: BLE001 - text = "" - cache[p] = text or "" - text = cache.get(p) or "" - if not text: - continue - chunk = text[: max(0, max_total - total)] - total += len(chunk) - parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}') - return ("\n\n".join(parts), cache) +"""Structure (RAG) tab — knowledge graph of code / document structure. + +Primary view is the D3 knowledge-graph (WebEngine) which gently auto-rotates +when idle and opens a node's storage folder on click. If WebEngine isn't +available (e.g. the standalone .exe), a native draggable QGraphicsView is the +in-app fallback. The graph auto-updates when the Code agent produces output, +and an Agent box on the right answers questions over the graph (Graph-RAG). +""" +from __future__ import annotations + +import math +import re +import sys +from pathlib import Path + +from PySide6.QtCore import QObject, QPointF, Qt, QTimer, QUrl, Signal, Slot +from PySide6.QtGui import QBrush, QColor, QFont, QPen +from PySide6.QtWidgets import ( + QComboBox, QFileDialog, QGraphicsEllipseItem, QGraphicsLineItem, + QGraphicsScene, QGraphicsSimpleTextItem, QGraphicsView, QHBoxLayout, + QLabel, QLineEdit, QPushButton, QSplitter, QStackedWidget, + QTextBrowser, QVBoxLayout, QWidget, +) + +def _frozen_onefile() -> bool: + """True only for a PyInstaller ONEFILE build. Onefile extracts itself to a + temp dir (sys._MEIPASS under %TEMP%), where the QtWebEngine helper process + can't run — creating a QWebEngineView hard-crashes the app (reported as + "click the graph tab → app closes"). A ONEDIR build keeps _MEIPASS as the + ``_internal`` folder right next to the exe, where WebEngine works fine, so + it keeps the full embedded D3 view.""" + if not getattr(sys, "frozen", False): + return False + meipass = getattr(sys, "_MEIPASS", "") + if not meipass: + return False + try: + return Path(meipass).resolve().parent != Path(sys.executable).resolve().parent + except OSError: # can't tell → play safe: use the native fallback + return True + + +try: # WebEngine + WebChannel are optional PySide6 add-ons + from PySide6.QtWebEngineWidgets import QWebEngineView + from PySide6.QtWebChannel import QWebChannel + _HAS_WEB = not _frozen_onefile() +except Exception: # pragma: no cover + _HAS_WEB = False + +from ..core.structure_graph import EDGE_KIND_COLORS, NODE_KIND_COLORS +from ..theme import current_palette +from ..core.worker import AgentWorker +from ..i18n import on_language_changed, tr +from ..state import AppContext +from .icons import collapse_right_icon, icon +from .osutil import open_folder, open_location +from .widgets import CollapseStrip + +try: + from PySide6.QtWidgets import QGraphicsItem # noqa: F401 — ensure available +except Exception: + pass + + +class _Bridge(QObject): + """Exposed to the D3 page so a Shift+click on a node can open its + storage folder/link (local path or URL — see osutil.open_location).""" + + @Slot(str) + def openPath(self, path: str) -> None: # noqa: N802 - JS-facing name + if path: + open_location(path) + + +class _Edge(QGraphicsLineItem): + def __init__(self, a: "_Node", b: "_Node", type_: str = ""): + super().__init__() + self.a, self.b = a, b + self.type = type_ + # Colour the edge by its RELATIONSHIP type (contains/defines/method/…), + # so the graph shows what each connection MEANS — falling back to the + # source node's tint for any untyped edge. + color = QColor(EDGE_KIND_COLORS.get(type_, "")) if type_ else QColor() + if not color.isValid(): + color = a.brush().color().lighter(130) + self._color = color + self.setPen(QPen(color, 1.4)) + self.setZValue(-1) + # A small label naming the relationship, shown at the edge midpoint. + self._label = None + if type_: + self._label = QGraphicsSimpleTextItem(type_, self) + self._label.setBrush(QBrush(color.lighter(140))) + f = QFont() + f.setPointSize(7) + self._label.setFont(f) + self._label.setZValue(0) + a.edges.append(self) + b.edges.append(self) + self.adjust() + + def adjust(self) -> None: + pa, pb = self.a.scenePos(), self.b.scenePos() + self.setLine(pa.x(), pa.y(), pb.x(), pb.y()) + if self._label is not None: + br = self._label.boundingRect() + self._label.setPos((pa.x() + pb.x()) / 2 - br.width() / 2, + (pa.y() + pb.y()) / 2 - br.height() / 2) + + +class _Node(QGraphicsEllipseItem): + def __init__(self, data, radius: int): + super().__init__(-radius, -radius, 2 * radius, 2 * radius) + self.data = data + self.edges = [] + tok = current_palette() + # NODE_KIND_COLORS is a categorical data encoding (one hue per node + # kind), not UI chrome — it stays fixed across themes on purpose so a + # given kind is always the same colour. Only the chrome follows tokens. + color = QColor(NODE_KIND_COLORS.get(data.kind, tok.text_muted)) + self.setBrush(QBrush(color)) + self.setPen(QPen(color.darker(160), 1.5)) + self.setFlags( + QGraphicsEllipseItem.ItemIsMovable + | QGraphicsEllipseItem.ItemIsSelectable + | QGraphicsEllipseItem.ItemSendsGeometryChanges + ) + self.setZValue(1) + label = QGraphicsSimpleTextItem(data.label, self) + label.setBrush(QBrush(QColor(tok.text))) + label.setPos(radius + 3, -8) + + def itemChange(self, change, value): # noqa: N802 + if change == QGraphicsEllipseItem.ItemPositionHasChanged: + for edge in self.edges: + edge.adjust() + return super().itemChange(change, value) + + +class _GraphView(QGraphicsView): + def __init__(self, scene): + super().__init__(scene) + self.setDragMode(QGraphicsView.NoDrag) + self._panning = False + self._pan_start = QPointF() + + def wheelEvent(self, e): # noqa: N802 + self.scale(1.15 if e.angleDelta().y() > 0 else 1 / 1.15, + 1.15 if e.angleDelta().y() > 0 else 1 / 1.15) + + def mousePressEvent(self, e): # noqa: N802 + if e.button() == Qt.LeftButton and self.itemAt(e.pos()) is None: + self._panning = True + self._pan_start = e.position() + self.setCursor(Qt.ClosedHandCursor) + e.accept() + return + super().mousePressEvent(e) + + def mouseMoveEvent(self, e): # noqa: N802 + if self._panning: + delta = e.position() - self._pan_start + self._pan_start = e.position() + self.horizontalScrollBar().setValue(int(self.horizontalScrollBar().value() - delta.x())) + self.verticalScrollBar().setValue(int(self.verticalScrollBar().value() - delta.y())) + e.accept() + return + super().mouseMoveEvent(e) + + def mouseReleaseEvent(self, e): # noqa: N802 + if self._panning: + self._panning = False + self.setCursor(Qt.ArrowCursor) + e.accept() + return + super().mouseReleaseEvent(e) + + def mouseDoubleClickEvent(self, e): # noqa: N802 + """Double-click or Ctrl+click on a node opens its storage folder.""" + item = self.itemAt(e.pos()) + if isinstance(item, _Node) and getattr(item.data, "path", ""): + open_folder(item.data.path) + e.accept() + return + super().mouseDoubleClickEvent(e) + + +class StructureGraphView(QWidget): + status_message = Signal(str) + + def __init__(self, ctx: AppContext): + super().__init__() + self.ctx = ctx + self._worker: AgentWorker | None = None + self._node_items: list[_Node] = [] + self._edge_items: list[_Edge] = [] + self._centroid = QPointF(0, 0) + self._link = 120 + self._graph = None + self._needs_scan = False + self._scan_seq = 0 # only the latest scan's result is rendered (no stale overwrite) + self._ask_worker: AgentWorker | None = None + self._answer = "" + self._detail_mode = "idle" # "answer" | "node" | "idle" — what self.detail shows + # TEMPORARY extracted file content for Q&A (real content, not just the + # graph structure). Kept only while this tab is shown — cleared on leaving + # the tab or switching project/root (see _clear_extracts / hideEvent). + self._extract_cache: dict = {} # path -> extracted text + self._extract_dir = None # temp folder for md/json dumps + self._active_project_id = "" # "" = free path; set = scan locked to that project's sandbox + + self._rescan_timer = QTimer(self) + self._rescan_timer.setSingleShot(True) + self._rescan_timer.setInterval(1500) + self._rescan_timer.timeout.connect(self._scan) + + root = QVBoxLayout(self) + + bar = QHBoxLayout() + self.path_edit = QLineEdit(str(ctx.config.cowork_output_dir())) + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn = QPushButton() + self._pick_btn.setIcon(icon("folder")) + self._pick_btn.setObjectName("primary") + self._pick_btn.clicked.connect(self._pick) + self.project_combo = QComboBox() + self.project_combo.currentIndexChanged.connect(self._on_project_changed) + self._scan_btn = QPushButton() + self._scan_btn.setIcon(icon("search")) + self._scan_btn.setObjectName("primary") + self._scan_btn.clicked.connect(self._scan) + bar.addWidget(self.path_edit, 1) + bar.addWidget(self._pick_btn) + bar.addWidget(self.project_combo) + bar.addWidget(self._scan_btn) + root.addLayout(bar) + self._refresh_project_combo() + + # Toolbar: messages toggle + export + bar2 = QHBoxLayout() + bar2.addStretch(1) + self._msgs_toggle_btn = QPushButton() + self._msgs_toggle_btn.setIcon(icon("message")) + self._msgs_toggle_btn.setToolTip(tr("structure.msgs_tooltip")) + self._msgs_toggle_btn.clicked.connect(self._toggle_messages) + bar2.addWidget(self._msgs_toggle_btn) + + self._export_btn = QPushButton() + self._export_btn.setIcon(icon("upload")) + self._export_btn.setObjectName("primary") + self._export_btn.clicked.connect(self._export) + bar2.addWidget(self._export_btn) + + root.addLayout(bar2) + + split = QSplitter(Qt.Horizontal) + self.scene = QGraphicsScene() + self.scene.setBackgroundBrush(QColor(current_palette().bg)) + self.scene.selectionChanged.connect(self._on_selection) + self.view = _GraphView(self.scene) + + self._stack = QStackedWidget() + self._stack.addWidget(self.view) + # A "Messages" view: all conversation messages grouped BY DAY, shown as + # JSON — a plain tree switched in via setCurrentWidget (never touches the + # D3/WebEngine graph). Populated from the (project-scoped) history store. + from PySide6.QtWidgets import QTreeWidget + self._msgs_view = QTreeWidget() + self._msgs_view.setHeaderHidden(True) + self._msgs_view.itemClicked.connect(self._show_msg_json) + self._stack.addWidget(self._msgs_view) + self.web = None + self._bridge = None + self._channel = None + + # The legend + Show-relationship control live INSIDE the D3 graph + # template now (assets/graph_template.html) — the graph column is just + # the stack (native view / D3 web / messages). + split.addWidget(self._stack) + + # Right-side agent panel (GraphRAG Q&A) + right = QWidget() + rl = QVBoxLayout(right) + rl.setContentsMargins(0, 0, 0, 0) + + # Agent panel header with collapse button + ag_hdr = QHBoxLayout() + self._ag_collapse = QPushButton() + self._ag_collapse.setIcon(collapse_right_icon()) + self._ag_collapse.setFixedWidth(28) + self._ag_collapse.clicked.connect(lambda: self._set_agent_collapsed(True)) + self._ag_label = QLabel() + ag_hdr.addWidget(self._ag_collapse) + ag_hdr.addWidget(self._ag_label, 1) + rl.addLayout(ag_hdr) + + # Ask row + ask_row = QHBoxLayout() + self.ask_edit = QLineEdit() + self.ask_edit.returnPressed.connect(self._ask) + self._ask_btn = QPushButton() + self._ask_btn.setIcon(icon("chat")) + self._ask_btn.setObjectName("primary") + self._ask_btn.clicked.connect(self._ask) + ask_row.addWidget(self.ask_edit, 1) + ask_row.addWidget(self._ask_btn) + rl.addLayout(ask_row) + + # Detail browser + self.detail = QTextBrowser() + self.detail.setReadOnly(True) + self.detail.setOpenLinks(False) + self.detail.anchorClicked.connect(self._on_detail_link) + rl.addWidget(self.detail, 1) + + self._agent_panel = right + + self._agent_strip = CollapseStrip(tr("structure.expand_agent_tooltip"), expand_dir="left") + self._agent_strip.clicked.connect(lambda: self._set_agent_collapsed(False)) + self._agent_strip.setVisible(False) + self._agent_pane = QWidget() + apl = QHBoxLayout(self._agent_pane) + apl.setContentsMargins(0, 0, 0, 0) + apl.setSpacing(0) + apl.addWidget(self._agent_strip) + apl.addWidget(right, 1) + + self._split = split + split.addWidget(self._agent_pane) + split.setChildrenCollapsible(False) + split.setSizes([840, 320]) + root.addWidget(split, 1) + on_language_changed(self._retranslate) + + def _retranslate(self) -> None: + self.path_edit.setPlaceholderText(tr("structure.path_placeholder")) + self._pick_btn.setText(tr("structure.browse")) + self._scan_btn.setText(tr("structure.scan")) + self._export_btn.setText(tr("structure.export_png")) + showing = self._stack.currentWidget() is getattr(self, "_msgs_view", None) + self._msgs_toggle_btn.setText(tr("structure.graph_btn") if showing else tr("structure.msgs_btn")) + self._ag_collapse.setToolTip(tr("structure.collapse_agent_tooltip")) + self._ag_label.setText(tr("structure.agent_header")) + self.ask_edit.setPlaceholderText(tr("structure.ask_placeholder")) + self._ask_btn.setText(tr("structure.ask")) + if self._detail_mode == "idle": + self.detail.setPlaceholderText(tr("structure.detail_placeholder")) + self._agent_strip.setToolTip(tr("structure.expand_agent_tooltip")) + self.project_combo.setToolTip(tr("structure.project_tooltip")) + self._refresh_project_combo() + + # ---- project sandbox lock ----------------------------------------- + def _refresh_project_combo(self) -> None: + from ..core.projects import list_projects + + keep = self._active_project_id + self.project_combo.blockSignals(True) + self.project_combo.clear() + self.project_combo.addItem(tr("structure.project_none"), "") + row_to_select = 0 + for i, p in enumerate(list_projects(), start=1): + self.project_combo.addItem(p.name, p.project_id) + if p.project_id == keep: + row_to_select = i + self.project_combo.setCurrentIndex(row_to_select) + self.project_combo.blockSignals(False) + + def set_project(self, project_id: str) -> None: + pid = project_id or "" + self._refresh_project_combo() + target = self.project_combo.findData(pid) + if target < 0: + target = 0 + if self.project_combo.currentIndex() == target: + self._on_project_changed(target) + else: + self.project_combo.setCurrentIndex(target) + + def _on_project_changed(self, _idx: int) -> None: + from ..core.projects import load_project + + pid = self.project_combo.currentData() or "" + project_changed = pid != self._active_project_id + if project_changed: + self._clear_extracts() # different workspace → drop temp extraction + self._active_project_id = pid + locked = bool(pid) + self.path_edit.setReadOnly(locked) + # Also disable the folder-pick button — otherwise the scan path is only + # "locked" against typing, but the picker could still repoint it outside + # the selected project's sandbox, breaking GraphRAG scope isolation. + self._pick_btn.setEnabled(not locked) + if locked: + project = load_project(pid) + if project is not None: + self.path_edit.setText(str(project.workspace_dir())) + if project_changed: + self._needs_scan = True + if self.web is not None: + self._needs_scan = False + self._scan() + + # ---- helpers ----------------------------------------------------- + def _pick(self) -> None: + chosen = QFileDialog.getExistingDirectory(self, tr("structure.pick_folder_title"), self.path_edit.text()) + if chosen: + self.path_edit.setText(chosen) + + def schedule_rescan(self, path: str = "") -> None: + if self._graph is None: + self._needs_scan = True + return + self._rescan_timer.start() + + # ---- Messages (by day, as JSON) -------------------------------------- + def _toggle_messages(self) -> None: + """Switch between the knowledge graph and the Messages-by-day view.""" + showing = self._stack.currentWidget() is self._msgs_view + if showing: + self._stack.setCurrentWidget(self.web if self.web is not None else self.view) + else: + self._reload_messages() + self._stack.setCurrentWidget(self._msgs_view) + self._msgs_toggle_btn.setText( + tr("structure.graph_btn") if not showing else tr("structure.msgs_btn")) + + def _reload_messages(self) -> None: + """Build the tree: day → conversation. Click a conversation to see its + messages as JSON. Scoped to the current project (its history folder).""" + from collections import OrderedDict + + from PySide6.QtCore import Qt + from PySide6.QtWidgets import QTreeWidgetItem + + from ..core.history import list_conversations + self._msgs_view.clear() + pid = self._active_project_id or "" + by_day: "OrderedDict[str, list]" = OrderedDict() + try: + convs = list_conversations(self.ctx.config.history_dir()) + except Exception: # noqa: BLE001 + convs = [] + for conv in convs: + if pid and conv.get("project_id", "default") != pid: + continue + day = (conv.get("created") or "")[:10] or "—" + by_day.setdefault(day, []).append(conv) + if not by_day: + self._msgs_view.addTopLevelItem(QTreeWidgetItem([tr("structure.msgs_none")])) + return + for day in sorted(by_day, reverse=True): + convs_d = by_day[day] + day_item = QTreeWidgetItem([f"{day} ({len(convs_d)})"]) + for conv in convs_d: + it = QTreeWidgetItem([conv.get("title", "(untitled)")]) + it.setData(0, Qt.UserRole, str(conv.get("path", ""))) + day_item.addChild(it) + self._msgs_view.addTopLevelItem(day_item) + day_item.setExpanded(True) + + def _show_msg_json(self, item, _col: int = 0) -> None: + import html + import json + + from PySide6.QtCore import Qt + + from ..core.history import load_conversation + path = item.data(0, Qt.UserRole) + if not path: + return + try: + conv = load_conversation(path) + payload = {"title": conv.get("title", ""), "created": conv.get("created", ""), + "kind": conv.get("kind", ""), "project_id": conv.get("project_id", ""), + "messages": conv.get("messages", [])} + text = json.dumps(payload, ensure_ascii=False, indent=2) + except Exception as exc: # noqa: BLE001 + text = f"(could not read: {exc})" + self.detail.setHtml( + f'
    {html.escape(text)}
    ') + + def _ensure_web(self) -> None: + if self.web is not None or not _HAS_WEB: + return + self.web = QWebEngineView() + self._bridge = _Bridge() + self._channel = QWebChannel() + self._channel.registerObject("py", self._bridge) + self.web.page().setWebChannel(self._channel) + self._stack.addWidget(self.web) + self._stack.setCurrentWidget(self.web) + if self._graph is not None: + self._render_d3() + + def auto_scan_and_fit(self) -> None: + self._ensure_web() + if not self.path_edit.text().strip(): + return + if getattr(self, "_worker", None) is not None and self._worker.isRunning(): + self._fit() + self._preserve_answer() + return + if self._graph is not None and not self._needs_scan: + self._fit() + self._preserve_answer() + return + self._needs_scan = False + self._scan() + + # ---- scan -------------------------------------------------------- + def _scan(self) -> None: + path = self.path_edit.text().strip() or str(Path.cwd()) + mode = "files" # default: scan all files (filter removed) + use_cmem = bool(self.ctx.config.codebase_memory.get("enabled")) + cmem_bin = self.ctx.config.codebase_memory.get("binary_path", "") + st = self.ctx.config.structure + max_nodes = int(st.get("max_nodes", 500) or 0) + max_edges = int(st.get("max_edges", 500) or 0) + self._scan_seq += 1 + seq = self._scan_seq + self.status_message.emit(tr("structure.scanning")) + + def job(worker: AgentWorker): + from ..core.structure_graph import ( + build_from_codebase_memory, build_from_directory, force_layout, + ) + if use_cmem: + from ..core.codebase_memory import CodebaseMemory + mem = CodebaseMemory(cmem_bin) + graph = (build_from_codebase_memory(mem, path, mode, max_nodes, max_edges) + if mem.available else build_from_directory(path, mode, max_nodes, max_edges)) + else: + graph = build_from_directory(path, mode, max_nodes, max_edges) + pos = force_layout(graph) + return {"graph": graph, "pos": pos, "seq": seq} + + w = AgentWorker(job) + w.finished_ok.connect(self._render) + w.failed.connect(lambda e: self.status_message.emit(tr("structure.scan_error", err=e))) + self._worker = w + w.start() + + def _render(self, result: dict) -> None: + if result.get("seq") is not None and result["seq"] != self._scan_seq: + return + graph = result.get("graph") + pos = result.get("pos", {}) + if graph is None: + return + self._graph = graph + + self.scene.clear() + self.scene.setBackgroundBrush(QColor(current_palette().bg)) # restore after clear + self._node_items = [] + self._edge_items = [] + degree = {n.id: 0 for n in graph.nodes} + for e in graph.edges: + if e.source in degree: + degree[e.source] += 1 + if e.target in degree: + degree[e.target] += 1 + items = {} + sx = sy = 0.0 + for node in graph.nodes: + radius = int(8 + min(20, 2.2 * math.sqrt(degree.get(node.id, 0)))) + item = _Node(node, radius) + x, y = pos.get(node.id, (0, 0)) + item.setPos(x, y) + self.scene.addItem(item) + items[node.id] = item + self._node_items.append(item) + sx += x + sy += y + for edge in graph.edges: + a, b = items.get(edge.source), items.get(edge.target) + if a and b: + e = _Edge(a, b, getattr(edge, "type", "")) + self.scene.addItem(e) + self._edge_items.append(e) + n = max(1, len(self._node_items)) + self._centroid = QPointF(sx / n, sy / n) + self._fit() + + if self.web is not None: + self._render_d3() + + note = tr("structure.truncated_note") if getattr(graph, "truncated", False) else "" + self.status_message.emit(tr( + "structure.graph_summary", nodes=len(graph.nodes), edges=len(graph.edges), note=note)) + self._preserve_answer() + + def _render_d3(self) -> None: + if self.web is None or self._graph is None: + return + from ..core.d3_graph import build_html + try: + self.web.setHtml(build_html(self._graph), QUrl("https://cowork.local/")) + except Exception as exc: + self.status_message.emit(f"D3 view error: {exc}") + + # ---- native interactions ---------------------------------------- + def _on_selection(self) -> None: + for item in self.scene.selectedItems(): + if isinstance(item, _Node): + d = item.data + self.detail.setPlainText(f"[{d.kind.upper()}] {d.label}\n\n{d.detail}") + self._detail_mode = "node" + return + + def _preserve_answer(self) -> None: + if self._detail_mode == "answer" and self._answer.strip(): + self._render_answer() + + def _set_agent_collapsed(self, collapsed: bool) -> None: + strip_w = CollapseStrip.WIDTH + 2 + self._agent_panel.setVisible(not collapsed) + self._agent_strip.setVisible(collapsed) + if collapsed: + self._agent_pane.setMaximumWidth(strip_w) + sizes = self._split.sizes() + if len(sizes) == 2: + self._split.setSizes([max(1, sum(sizes) - strip_w), strip_w]) + else: + self._agent_pane.setMaximumWidth(16777215) + self._split.setSizes([840, 320]) + + def _fit(self) -> None: + if self.web is not None and self._stack.currentWidget() is self.web: + self.web.page().runJavaScript("window.fitGraph && window.fitGraph();") + return + rect = self.scene.itemsBoundingRect() + if not rect.isNull(): + self.view.fitInView(rect.adjusted(-40, -40, 40, 40), Qt.KeepAspectRatio) + + def _export(self) -> None: + path, _ = QFileDialog.getSaveFileName( + self, tr("structure.export_title"), "structure-graph.png", "PNG (*.png)") + if not path: + return + showing_d3 = (self.web is not None and self._stack.currentWidget() is self.web) + if showing_d3: + self._export_d3_png(path) + else: + self._export_widget_grab(path) + + def _export_d3_png(self, path: str) -> None: + def on_result(data_url) -> None: + if not isinstance(data_url, str) or "," not in data_url: + self._export_widget_grab(path) + return + import base64 + try: + with open(path, "wb") as f: + f.write(base64.b64decode(data_url.split(",", 1)[1])) + self.status_message.emit(tr("structure.export_done", path=path)) + except (OSError, ValueError) as exc: + self.status_message.emit(tr("structure.export_failed", err=str(exc))) + self.web.page().runJavaScript("window.exportPng ? window.exportPng() : ''", on_result) + + def _export_widget_grab(self, path: str) -> None: + ok = self._stack.currentWidget().grab().save(path, "PNG") + if ok: + self.status_message.emit(tr("structure.export_done", path=path)) + else: + self.status_message.emit(tr("structure.export_failed", err="grab() returned no image")) + + # ---- agent Q&A over the graph ----------------------------------- + @staticmethod + def _graph_context(graph) -> str: + from collections import defaultdict + by_kind = defaultdict(list) + for n in graph.nodes: + by_kind[n.kind].append(n.label) + lines = [] + for kind in ("file", "class", "function", "method", "module", "section"): + items = by_kind.get(kind, []) + if items: + lines.append(f"{kind} ({len(items)}): " + ", ".join(items[:60])) + id2label = {n.id: n.label for n in graph.nodes} + rels = [f"{id2label.get(e.source, e.source)} -{e.type}-> {id2label.get(e.target, e.target)}" + for e in graph.edges[:140]] + if rels: + lines.append("Relationships (sample):\n" + "\n".join(rels)) + return "\n".join(lines)[:7000] + + def _matched_sources(self, text: str): + if self._graph is None or not text: + return [] + found: dict[str, tuple[str, str, str]] = {} + for n in self._graph.nodes: + if not n.path: + continue + label = n.label.rstrip("()") + if len(label) < 3: + continue + if n.path not in found and re.search(rf"\b{re.escape(label)}\b", text): + found[n.path] = (n.kind, n.label, n.detail or n.path) + return sorted(found.items(), key=lambda kv: kv[1][1].lower())[:12] + + def _linkify_files(self, text: str, sources) -> str: + """Turn file/entity NAMES mentioned in the answer into clickable links that + open the file — so the user can click a name in the answer to view it.""" + for path, (kind, label, rel) in sources: + href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) + tokens = [] + base = Path(path).name + if base and len(base) >= 3: + tokens.append(base) + lab = (label or "").rstrip("()").strip() + if lab and lab != base and len(lab) >= 3: + tokens.append(lab) + for tok in tokens: + esc = re.escape(tok) + # `tok` (code span) → keep the code style but make it a link + text = re.sub(rf"`{esc}`", f"[`{tok}`]({href})", text) + # bare tok, not already inside a link / path / code span + text = re.sub(rf"(? None: + text = self._answer + sources = self._matched_sources(text) + if sources: + # 1) Make the file/entity names IN THE ANSWER clickable (open on click). + text = self._linkify_files(text, sources) + # 2) Append a clickable "Related sources" section listing each file. + lines = [text, "", "---", f"**{tr('structure.related_sources')}**"] + for path, (kind, label, rel) in sources: + href = QUrl.fromLocalFile(path).toString(QUrl.ComponentFormattingOption.FullyEncoded) + # kind badge for context (file/function/section/json_key) + kind_badge = f" [{kind.upper()}]" if kind not in ("file",) else "" + lines.append(f"- **[{label}⧉]({href})**{kind_badge} — `{rel}`") + text = "\n".join(lines) + self.detail.setMarkdown(text) + + def _on_detail_link(self, url: QUrl) -> None: + if url.isLocalFile(): + p = url.toLocalFile() + # Open the FILE itself for viewing (fall back to its folder for a dir). + if Path(p).is_file(): + open_location(p) + else: + open_folder(p) + + def _ask(self) -> None: + question = self.ask_edit.text().strip() + if not question: + return + from ..core.skills import parse_skill_command + skill_prefix, question, info = parse_skill_command(question) + if info is not None: + self.detail.setMarkdown(info) + self._detail_mode = "answer" + self.ask_edit.clear() + return + if self._graph is None: + self.status_message.emit(tr("structure.scan_first")) + return + context = self._graph_context(self._graph) + # Real file CONTENT to answer from (extracted temporarily in the worker): + file_paths = self._candidate_file_paths() + extract_cache = dict(self._extract_cache) + extract_dir = str(self._extract_tmp_dir()) + self._answer = "" + self._detail_mode = "answer" + self.detail.setPlainText("…") + self.ask_edit.clear() + + active_project_id = self._active_project_id + + # Collect selected node context for auto-filtering + selected_nodes = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] + selected_context = "" + if selected_nodes: + node_lines = [] + for nd in selected_nodes: + node_lines.append(f"- {nd.label} (kind: {nd.kind}, path: {getattr(nd, 'path', '')})") + if nd.detail: + node_lines.append(f" detail: {nd.detail}") + # Also gather connected nodes + connected_ids = set() + for nd in selected_nodes: + for edge in self._graph.edges: + if edge.source == nd.id: + connected_ids.add(edge.target) + elif edge.target == nd.id: + connected_ids.add(edge.source) + connected_nodes = [n for n in self._graph.nodes if n.id in connected_ids] + if connected_nodes: + node_lines.append("\nConnected nodes:") + for cn in connected_nodes: + node_lines.append(f"- {cn.label} (kind: {cn.kind})") + selected_context = "\n".join(node_lines) + + def job(worker: AgentWorker): + provider = self.ctx.build_active_provider() + system = ("You answer questions about a code/document knowledge graph. Use the provided " + "graph context AND the extracted file contents to retrieve, synthesize and " + "explain the answer. Be concise. Answer ONLY from what is provided (graph " + "context + extracted contents) — never invent files, functions, or facts that " + "aren't in it.\n\n" + "EACH answer MUST include source citations so the user can verify where " + "information came from. For every factual claim, file reference, or code " + "element you mention, add a citation using this format:\n\n" + " [source: filename.ext, line/section: XXX]\n\n" + "Rules for citations:\n" + " 1. Cite the EXACT file path from the graph context (use the path field).\n" + " 2. For Python files: cite the function/class name and approximate line " + " if available, or the module name.\n" + " 3. For document files (.md, .txt): cite the section heading.\n" + " 4. For JSON files: cite the key path (e.g. settings > database > host).\n" + " 5. Place citations inline after the relevant sentence or fact.\n" + " 6. At the end of your answer, add a '---' separator followed by a " + " numbered **Sources cited:** section listing each unique source with " + " its full path so the user can click to open it.\n\n" + "Example citation format in text:\n" + " The `process_data()` function handles CSV parsing " + "[source: src/utils/parser.py, function: process_data].\n\n" + "Example end-of-answer source list:\n" + " ---\n" + " **Sources cited:**\n" + " 1. `src/utils/parser.py` — process_data function\n" + " 2. `docs/api.md` — Section: Authentication\n") + if skill_prefix: + system += "\n\nFollow this skill:\n" + skill_prefix + if active_project_id: + from ..core.projects import load_project, project_context_text + proj_ctx = project_context_text(load_project(active_project_id)) + if proj_ctx: + system += "\n\n" + proj_ctx + user_content = f"Graph context:\n{context}" + if selected_context: + user_content += f"\n\nSelected node(s) context (focus your answer on these):\n{selected_context}" + # Auto-extract the actual file contents (temporary) so the answer is + # synthesized from real content, not just the graph structure. + content_block, new_cache = _extract_file_contents(file_paths, extract_cache, extract_dir) + if content_block: + user_content += ("\n\nExtracted file contents (read these to answer about file " + "details/data; cite the file path):\n" + content_block) + user_content += f"\n\nQuestion: {question}" + messages = [ + {"role": "system", "content": system}, + {"role": "user", "content": user_content}, + ] + from ..core import agent_roles, audit_log + ok = True + try: + provider.chat(messages, on_text=lambda t: worker.emit_event({"type": "text", "delta": t}), + cancel=worker.is_cancelled) + except Exception: + ok = False + raise + finally: + audit_log.record("tool_call", "graphrag_ask", ok, question[:500], + agent_role=agent_roles.KNOWLEDGE) + return {"extracted": new_cache} + + w = AgentWorker(job) + w.event.connect(self._on_ask_event) + w.finished_ok.connect(self._on_ask_done) + w.failed.connect(lambda e: self.detail.setPlainText(f"Error: {e}")) + self._ask_worker = w + w.start() + + def _on_ask_event(self, ev: dict) -> None: + if ev.get("type") == "text": + if self._answer == "": + self.detail.clear() + self._answer += ev.get("delta", "") + self.detail.setPlainText(self._answer) + + def _on_ask_done(self, result: dict) -> None: + # Keep the (temporary) extracted content so repeated questions reuse it + # without re-extracting — dropped when leaving the tab (_clear_extracts). + if isinstance(result, dict): + self._extract_cache.update(result.get("extracted", {}) or {}) + self._render_answer() + + # ---- temporary file-content extraction for Q&A ------------------------ + def _candidate_file_paths(self) -> list: + """File paths to read for a question: the SELECTED file nodes if any, else + every file node in the graph (capped downstream).""" + from pathlib import Path as _P + if self._graph is None: + return [] + sel = [item.data for item in self.scene.selectedItems() if isinstance(item, _Node)] + nodes = sel or list(self._graph.nodes) + out, seen = [], set() + for nd in nodes: + p = (getattr(nd, "path", "") or "").strip() + if p and p not in seen and _P(p).is_file(): + seen.add(p) + out.append(p) + return out + + def _extract_tmp_dir(self): + from pathlib import Path as _P + if self._extract_dir is None: + import tempfile + from ..config import CONFIG_DIR + base = CONFIG_DIR / "tmp" / "graphrag_extract" + base.mkdir(parents=True, exist_ok=True) + self._extract_dir = _P(tempfile.mkdtemp(dir=str(base))) + return self._extract_dir + + def _clear_extracts(self) -> None: + """Discard the temporary extracted content (on leaving the tab / switching + project). The extraction is a scratch aid, never persisted.""" + self._extract_cache = {} + d, self._extract_dir = self._extract_dir, None + if d is not None: + import shutil + shutil.rmtree(d, ignore_errors=True) + + def hideEvent(self, e): # noqa: N802 + # Leaving the GraphRAG tab → drop the temporary extracted info. + self._clear_extracts() + super().hideEvent(e) + + + + + +# -------------------------------------------------------------------------- +# Temporary file-content extraction for Graph-RAG Q&A (runs in the ask worker) +# -------------------------------------------------------------------------- +def _pdf_to_markdown(pdf_path, out_dir) -> str | None: + """Convert a PDF to Markdown with opendataloader-pdf when available (richer + structure than a plain text dump). Best-effort — returns None if the package + isn't installed or the call fails, so the caller falls back to doc_extract.""" + from pathlib import Path as _P + try: + import opendataloader_pdf # optional; auto-installed elsewhere if present + except Exception: # noqa: BLE001 + try: + from ..core.deps import ensure_module + if ensure_module("opendataloader_pdf", "opendataloader-pdf") is None: + return None + import opendataloader_pdf # noqa: F811 + except Exception: # noqa: BLE001 + return None + out = _P(out_dir) + out.mkdir(parents=True, exist_ok=True) + for call in ( + lambda: opendataloader_pdf.convert(input_path=[str(pdf_path)], output_dir=str(out), + generate_markdown=True), + lambda: opendataloader_pdf.convert(input_path=str(pdf_path), output_dir=str(out)), + lambda: opendataloader_pdf.convert(str(pdf_path), str(out)), + ): + try: + call() + break + except TypeError: + continue + except Exception: # noqa: BLE001 + return None + mds = list(out.rglob(_P(pdf_path).stem + "*.md")) or list(out.rglob("*.md")) + for md in mds: + try: + return md.read_text(encoding="utf-8", errors="replace") + except OSError: + continue + return None + + +def _extract_file_contents(paths, cache: dict, tmp_dir, + max_files: int = 15, max_total: int = 120_000): + """Read the ACTUAL content of ``paths`` (PDF→markdown via opendataloader when + available, else doc_extract for office/pdf/text). Returns ``(block, cache)`` + — ``block`` is the concatenated content for the prompt (bounded), ``cache`` + maps path→text for reuse. Never raises.""" + from pathlib import Path as _P + from ..core import doc_extract + cache = dict(cache or {}) + parts, total = [], 0 + for p in paths[:max_files]: + if total >= max_total: + break + text = cache.get(p) + if text is None: + try: + if _P(p).suffix.lower() == ".pdf": + text = _pdf_to_markdown(p, tmp_dir) + if not text: + text, _n = doc_extract.extract_text(p) + else: + text, _n = doc_extract.extract_text(p) + except Exception: # noqa: BLE001 + text = "" + cache[p] = text or "" + text = cache.get(p) or "" + if not text: + continue + chunk = text[: max(0, max_total - total)] + total += len(chunk) + parts.append(f'--- {_P(p).name} ({p}) ---\n{chunk}') + return ("\n\n".join(parts), cache) diff --git a/ui/task_editor_dialog.py b/ui/task_editor_dialog.py index 9d135d8..988e92a 100644 --- a/ui/task_editor_dialog.py +++ b/ui/task_editor_dialog.py @@ -75,12 +75,13 @@ class TaskEditorDialog(QDialog): # with a lighter box) — just a light outline, consistent with the rest of # the app. The combo drop-down popup keeps a solid dark background so its # items stay readable. + # Inputs in this dense form sit flat on the dialog rather than on their + # own raised surface — the app-wide sheet styles everything else here, + # including the combo popup, so nothing needs a colour override. self.setStyleSheet( "QLineEdit, QPlainTextEdit, QListWidget, QComboBox, QAbstractSpinBox {" - " background: transparent; border: 1px solid rgba(140,146,152,0.45);" - " border-radius: 6px; }" - "QListWidget::item { background: transparent; }" - "QComboBox QAbstractItemView { background: #111D32; color: #E0F0FF; }") + " background: transparent; }" + "QListWidget::item { background: transparent; }") outer = QVBoxLayout(self) scroll = QScrollArea() diff --git a/ui/terminal_panel.py b/ui/terminal_panel.py index dede3cf..2d7a728 100644 --- a/ui/terminal_panel.py +++ b/ui/terminal_panel.py @@ -27,6 +27,7 @@ from PySide6.QtWidgets import ( ) from ..i18n import on_language_changed, tr +from ..theme import current_palette from .icons import icon _IS_WIN = sys.platform == "win32" @@ -75,8 +76,10 @@ class TerminalPanel(QWidget): # ---- header (always visible; click to expand/collapse) -------------- self._header = QFrame() self._header.setObjectName("termHeader") + _tp = current_palette() self._header.setStyleSheet( - "#termHeader { background: rgba(0,0,0,0.06); border-radius: 6px; }") + f"#termHeader {{ background: {_tp.surface};" + f" border-radius: {_tp.radius}px; }}") hb = QHBoxLayout(self._header) hb.setContentsMargins(8, 4, 8, 4) self._toggle_btn = QPushButton() @@ -107,8 +110,7 @@ class TerminalPanel(QWidget): mono.setStyleHint(QFont.Monospace) mono.setPointSize(10) self.output.setFont(mono) - self.output.setStyleSheet( - "#termOutput { background: #1e1e1e; color: #d4d4d4; border: none; }") + # Surface comes from the central style sheet (#termOutput) — see theme.py. self.output.setMinimumHeight(160) bl.addWidget(self.output, 1) @@ -119,9 +121,7 @@ class TerminalPanel(QWidget): self.input = _TermInput() self.input.setObjectName("termInput") self.input.setFont(mono) - self.input.setStyleSheet( - "#termInput { background: #1e1e1e; color: #d4d4d4; border: 1px solid #3c3c3c; " - "border-radius: 6px; padding: 4px 8px; }") + # Surface comes from the central style sheet (#termInput) — see theme.py. self.input.returnPressed.connect(self._run_current) self.input.complete_requested.connect(self._complete) self.input.history_prev.connect(lambda: self._history_move(-1)) @@ -282,11 +282,12 @@ class TerminalPanel(QWidget): if not text: return from PySide6.QtGui import QColor, QTextCursor - colors = {"cmd": "#4ec9b0", "err": "#f48771", "ok": "#6a9955", "out": "#d4d4d4"} + p = current_palette() + colors = {"cmd": p.code_type, "err": p.code_error, "ok": p.code_comment, "out": p.code_fg} cursor = self.output.textCursor() cursor.movePosition(QTextCursor.End) fmt = cursor.charFormat() - fmt.setForeground(QColor(colors.get(role, "#d4d4d4"))) + fmt.setForeground(QColor(colors.get(role, p.code_fg))) cursor.setCharFormat(fmt) cursor.insertText(text) self.output.setTextCursor(cursor) diff --git a/ui/widgets.py b/ui/widgets.py index fa85d14..1cbe58c 100644 --- a/ui/widgets.py +++ b/ui/widgets.py @@ -8,36 +8,40 @@ from pathlib import Path from PySide6.QtCore import QEvent, QObject, QPointF, QRectF, Qt, Signal from PySide6.QtGui import QColor, QPainter, QPen from PySide6.QtWidgets import ( - QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QGraphicsDropShadowEffect, + QAbstractSpinBox, QComboBox, QDoubleSpinBox, QFrame, QHBoxLayout, QLabel, QListWidget, QListWidgetItem, QPushButton, QSizePolicy, QVBoxLayout, QWidget, ) from ..core.flows import STEP_DONE, STEP_ERROR, STEP_PENDING, STEP_RUNNING -from ..theme import ACCENT +from ..theme import current_palette from .icons import DOT_BLUE, DOT_GREEN, DOT_GREY, DOT_RED, dot_icon, icon +def _style_card(frame: QFrame) -> None: + """Give a stat/budget card its surface. Flat by design: the raised surface + plus a hairline is what separates it from the page — the old drop shadow + made a grid of these look like it was hovering off the screen.""" + p = current_palette() + frame.setStyleSheet( + f"QFrame {{ background: {p.surface}; border: 1px solid {p.border};" + f" border-radius: {p.radius_lg}px; }}") + + class StatCard(QFrame): """A titled value card (e.g. token count + its cost as the subtitle) — shared by Dashboard and Monitoring's token/cost displays.""" def __init__(self): super().__init__() - self.setFrameShape(QFrame.StyledPanel) - self.setStyleSheet( - "QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }") - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(18) - shadow.setOffset(0, 3) - shadow.setColor(QColor(0, 0, 0, 60)) - self.setGraphicsEffect(shadow) + self.setFrameShape(QFrame.NoFrame) + _style_card(self) lay = QVBoxLayout(self) self.title_lbl = QLabel("") self.title_lbl.setObjectName("hint") self.title_lbl.setStyleSheet("border: none;") self.value_lbl = QLabel("—") - self.value_lbl.setStyleSheet("border: none; font-size: 20px; font-weight: 700;") + self.value_lbl.setStyleSheet("border: none; font-size: 22px; font-weight: 600;") self.sub_lbl = QLabel("") self.sub_lbl.setObjectName("hint") self.sub_lbl.setStyleSheet("border: none;") @@ -66,20 +70,14 @@ class BudgetCard(QFrame): def __init__(self): super().__init__() - self.setFrameShape(QFrame.StyledPanel) - self.setStyleSheet( - "QFrame { border: 1px solid rgba(140,146,152,0.35); border-radius: 16px; }") - shadow = QGraphicsDropShadowEffect(self) - shadow.setBlurRadius(18) - shadow.setOffset(0, 3) - shadow.setColor(QColor(0, 0, 0, 60)) - self.setGraphicsEffect(shadow) + self.setFrameShape(QFrame.NoFrame) + _style_card(self) lay = QVBoxLayout(self) self.title_lbl = QLabel("") self.title_lbl.setObjectName("hint") self.title_lbl.setStyleSheet("border: none;") self.value_lbl = QLabel("—") - self._value_style = "border: none; font-size: 20px; font-weight: 700;" + self._value_style = "border: none; font-size: 22px; font-weight: 600;" self.value_lbl.setStyleSheet(self._value_style) self.sub_lbl = QLabel("") self.sub_lbl.setObjectName("hint") @@ -109,7 +107,7 @@ class BudgetCard(QFrame): self.title_lbl.setText(title) self.value_lbl.setText(value) self.value_lbl.setStyleSheet( - self._value_style + (" color: #E5484D;" if warn else "")) + self._value_style + (f" color: {current_palette().danger};" if warn else "")) self.sub_lbl.setText(sub) @@ -185,15 +183,16 @@ class CollapseStrip(QWidget): p = QPainter(self) p.setRenderHint(QPainter.Antialiasing) w = self.width() - accent = QColor(ACCENT) if self._hover else QColor("#8b8d98") + tok = current_palette() + accent = QColor(tok.accent) if self._hover else QColor(tok.text_faint) # A small rounded "button" at the top carries the expand arrow so the # collapsed panel always shows a clear, clickable affordance. bw = min(w - 2.0, 16.0) btn = QRectF((w - bw) / 2.0, 6.0, bw, 18.0) - p.setPen(QPen(QColor(139, 144, 150, 130), 1.0)) - p.setBrush(QColor(155, 160, 166, 70) if self._hover else QColor(155, 160, 166, 32)) - p.drawRoundedRect(btn, 4.0, 4.0) + p.setPen(QPen(QColor(tok.border_strong), 1.0)) + p.setBrush(QColor(tok.hover if self._hover else tok.surface)) + p.drawRoundedRect(btn, float(tok.radius_sm), float(tok.radius_sm)) cx = w / 2.0 cy = btn.center().y() @@ -211,7 +210,7 @@ class CollapseStrip(QWidget): # thin handle line below the button p.setPen(Qt.NoPen) - p.setBrush(QColor(155, 160, 166, 90)) + p.setBrush(QColor(tok.border_strong)) line_w = 2.0 x = (w - line_w) / 2.0 ltop = btn.bottom() + 6.0 @@ -226,7 +225,12 @@ class PlanSection(QWidget): close). Hidden until it has steps; updated in place as the agent calls ``update_plan``.""" - _COLORS = {STEP_RUNNING: ACCENT, STEP_DONE: "#6fe3a4", STEP_ERROR: "#ef6368"} + @staticmethod + def _step_color(status: str) -> str | None: + """Row text colour per step status; None leaves the default. Resolved + per call so it follows a live theme switch.""" + p = current_palette() + return {STEP_RUNNING: p.accent, STEP_DONE: p.success, STEP_ERROR: p.danger}.get(status) @staticmethod def _step_icon(status: str): @@ -272,7 +276,7 @@ class PlanSection(QWidget): continue status = str((s or {}).get("status", STEP_PENDING)).strip().lower() item = QListWidgetItem(self._step_icon(status), f" {title}") - color = self._COLORS.get(status) + color = self._step_color(status) if color: item.setForeground(QColor(color)) self.list.addItem(item)