Compare commits

..
5 Commits
Author SHA1 Message Date
a03a740ea1 Feature/perf ui logic (#13)
CI / test (push) Canceled after 0s
## Summary

Nhánh `feature/perf-ui-logic`: tối ưu hiệu năng/UI, sửa lỗi workspace và điều hướng, và làm cho công tắc **"Block network for agent-run commands"** chặn thật mọi đường ra mạng của app, **trừ nhà cung cấp AI**.

**Chặn mạng (b78d483, 8c497cf, 10b8379)**
- Bộ kiểm tra chung `application/network/network_guard.py`, nối vào cấu hình đang chạy ở Composition Root: đổi công tắc trong Settings là có hiệu lực ngay.
- Lệnh shell của agent và task script chạy trong **Windows AppContainer không có quyền mạng**: kernel chặn socket, ping, DNS, Invoke-WebRequest… Không cần quyền admin. Không cô lập được thì lệnh bị từ chối, không chạy khi mạng còn mở. macOS dùng `sandbox-exec`, Linux dùng `unshare --net`.
- Bật chặn thì: dừng MCP đang chạy, không khởi động server mới, từ chối lời gọi connector; Microsoft 365 (đăng nhập, Graph, đồng bộ cloud, rules, mail), Teams, nút Test REST/Jira/MCP, link đính kèm task, pip tự cài và tài nguyên web trong xem trước HTML đều bị từ chối.
- Vẫn dùng được: chat, tải danh sách model, thử model; tool OneDrive đã đồng bộ trên máy.
- Công tắc **mặc định tắt** khi mở app lần đầu; nhãn giữ nguyên như cũ.
- Xem trước HTML trong tab Folder giờ hiện được ảnh/CSS/JS từ web khi mạng mở (trước đây trang `file://` không tải được).
- Sửa lỗi app văng khi chuyển tab Graph → Folder: profile WebEngine của trang xem trước bị huỷ trước trang (`0xc0000409` trong Qt6Core.dll); giờ dùng một profile chung thuộc QApplication.
- Không cấp quyền AppContainer kế thừa lên thư mục chứa PySide6 (nếu có, Chromium không nạp được `Qt6WebEngineCore.dll` và tab Graph trắng).
- Cột mục lục trong Settings tính độ rộng theo kiểu chữ của mục đang chọn, "Sandbox Security Layer" không còn bị cắt.

**Các commit khác trong nhánh**
- `b7a41b3` mỗi thư mục làm việc chỉ thuộc về một project · `bbdf146` bật nút Sửa project khi đã có project đang mở
- `35f24e0`, `cc8d5c8`, `2e3e719`, `c699beb` canh hàng / khoảng cách thanh điều hướng
- `2759ed9` không refresh workspace khi chuyển tab Cowork · `7607f44` checkpoint hiệu năng và UI
- `8548c1e` chặn tool mạng của agent · `caf3b74` renderer GraphRAG native trên macOS · `c00b83c` khoảng cách metadata hàng project · `a04f8a9` ẩn picker workspace cloud

## Change Type

- [x] Cowork feature
- [x] Bug fix
- [ ] Core AI contribution
- [x] Test / hardening
- [x] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?
- Mọi đường ra mạng do app tự mở, trừ nhà cung cấp AI (xem Summary).
- Test: `tests/test_network_guard_lanes.py` (có bài chạy AppContainer thật trên Windows), `tests/ui/test_html_preview_remote_images.py`.

What is intentionally NOT included?
- Chặn cả nhà cung cấp AI / chạy model trên máy (Phương án 2).
- Terminal người dùng tự gõ trong tab Folder, sinh ảnh, cơ chế tự tin chứng chỉ lạ (`tls_trust`).
- Huy hiệu trạng thái "đang chặn" trên thanh trên cùng.

## Validation

- [x] Unit tests
- [x] Integration tests
- [x] Manual verification
- [x] Regression check

Commands / evidence:
- `python -m pytest tests/test_network_guard_lanes.py tests/test_sandbox_block_network.py tests/ui -q` → chỉ còn 1 bài fail, fail cả trên `b7a41b3` (nhãn `ProjectRow` 'Project' chưa dịch, `tests/ui/test_i18n_khong_con_chu_cu.py`).
- `python -m pytest tests -q --ignore=tests/ui` → 4 bài fail, cả 4 cũng fail trên `b7a41b3` (`test_canonical_audit_logger`, 2 bài `test_mcp_audit_security`, `test_monitoring_tab_container`).
- Chạy cả `tests` trong một lượt thì treo ở các test dựng MainWindow trong `tests/ui`; `b7a41b3` cũng treo đúng chỗ đó.
- `check_imports.py` và `check_orphan_modules.py` PASS. `check_loc.py` báo 9 file quá dài, giống hệt trước khi sửa (không file nào do nhánh này làm dài thêm).
- Kiểm tra tay trên Windows 11: trong AppContainer, Python báo `WinError 10013`, ping/nslookup/PowerShell/curl đều không ra được mạng; cmd, git, python chạy bình thường.
- Kiểm tra tay trên Windows 11: xem trước HTML tải được 4/4 tài nguyên web khi mạng mở, 0/4 khi bật chặn; tab Graph hoạt động; tạo/huỷ trang xem trước nhiều lần không còn cảnh báo profile của Qt.

## Security Impact

Permission / credential / network / customer data impact:
- Network: khi bật công tắc, chỉ nhà cung cấp AI còn ra mạng; nội dung chat vẫn gửi tới nhà cung cấp AI.
- Permission: lần đầu chạy lệnh trong sandbox, app **thêm quyền (ACE) cho SID AppContainer** trên thư mục làm việc (ghi), thư mục cài Python gốc (đọc), gốc venv và `Scripts` (đọc). Không xoá quyền nào. Thư mục chứa PySide6 không bao giờ nhận quyền kế thừa; một quyền kế thừa sai trên venv (từ bản dev trước) được tự gỡ.
- Credential: không đổi. Khi chặn, trạng thái đăng nhập M365 được đọc thẳng từ kho token trên máy, không dựng MSAL.

## Compatibility

- [x] No breaking change
- [ ] Breaking change documented

Ghi chú: `block_network` mặc định đổi từ bật sang tắt cho cấu hình mới; máy đã lưu `true` thì giữ nguyên. Khi đang chặn, lệnh dùng công cụ cài trong thư mục người dùng (ngoài Program Files) có thể báo Access denied; thư viện trong venv của app không dùng được trong sandbox.

## Reviewer Notes

- `infrastructure/sandbox/appcontainer_process.py` gọi Win32 bằng ctypes (CreateAppContainerProfile, CreateProcessW với SECURITY_CAPABILITIES) và dùng `icacls` để cấp quyền: nên xem kỹ phần cấp quyền.
- `tests/conftest.py` thêm fixture autouse gỡ `network_guard` sau mỗi test, vì `build_context()` gắn cổng này ở mức process.
- `core/task_executors.py` đang đúng bằng trần LOC nên `_run_script` được tách sang `core/task_script.py`.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

---------

Co-authored-by: minhanhpkpro <minhanhpkpro@gmail.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Reviewed-on: #13
2026-09-20 12:26:03 +00:00
cbae2604db Fix/qa defects df002 df003 df006 (#12)
CI / test (push) Canceled after 0s
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Reviewed-on: #12
2026-09-14 13:15:53 +00:00
duylh19andgitea-admin b71a622227 Delta team/fix comment ui v2 (#11)
CI / test (push) Canceled after 0s
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

Reviewed-on: #11
Co-authored-by: Duy Le Huu <duylh19@fpt.com>
2026-09-14 13:15:40 +00:00
1b8429e33a Delta team/fix comment ui (#10)
CI / test (push) Canceled after 0s
fix các bug theo yêu cầu
https://fptsoftware362-my.sharepoint.com/❌/g/personal/nampdt_fpt_com/IQAHBJ4A9xqDTLgvt2bhukJEAdRB5LRz2hbJpTivvIiBSYM?wdExp=TEAMS-TREATMENT&web=1&isSPOFile=1&ovuser=f01e930a-b52e-42b1-b70f-a8882b5d043b%2CAnhTNM1%40fpt.com&clickparams=eyJBcHBOYW1lIjoiVGVhbXMtRGVza3RvcCIsIkFwcFZlcnNpb24iOiI0OS8yNjA4MTMxOTMxNyIsIkhhc0ZlZGVyYXRlZFVzZXIiOmZhbHNlfQ%3D%3D

---------

Co-authored-by: Duy Le Huu <duylh19@fpt.com>
Reviewed-on: #10
Co-authored-by: Anh Tran Nguyen Minh <anhtnm1@fpt.com>
2026-09-09 16:46:15 +00:00
13e2c22067 Fix/qa defects df002 df011 (#9)
CI / test (push) Canceled after 0s
## Summary

What changed and why?

## Change Type

- [ ] Cowork feature
- [ ] Bug fix
- [ ] Core AI contribution
- [ ] Test / hardening
- [ ] Performance
- [ ] Documentation

## Related Work

Cowork Task:

Core Repo: http://34.143.229.138/gitea-admin/fsg-ai-core-assets

Core AI Issue:

Core Task:

Related PR:

## Scope

What is intentionally included?

What is intentionally NOT included?

## Validation

- [ ] Unit tests
- [ ] Integration tests
- [ ] Manual verification
- [ ] Regression check

Commands / evidence:

## Security Impact

Permission / credential / network / customer data impact:

## Compatibility

- [ ] No breaking change
- [ ] Breaking change documented

## Reviewer Notes

Anything Cowork reviewers should pay attention to.

---------

Co-authored-by: thanhnv <thanhnv.ip@gmail.com>
Co-authored-by: Vu Dam Tuan <vudt15@fpt.com>
Reviewed-on: #9
2026-09-09 16:19:31 +00:00
136 changed files with 5282 additions and 668 deletions
+82
View File
@@ -0,0 +1,82 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Project
Cowork Local (user-facing brand "Cowork-Local BamBOO") is a local-first PySide6 desktop app: multi-turn AI agents, per-project workspaces with GraphRAG, scheduled agent tasks (Kanban), MCP connectors, a sandbox security layer, model routing, and a monitoring dashboard. User config lives in `~/.cowork_local`. The internal name `cowork_local` / `APP_NAME` must not be rebranded. Only `DISPLAY_NAME` is the brand.
## The package-name quirk (read first)
The repository root **is** the `cowork_local` package: `__init__.py` and `__main__.py` sit at the root, and code imports itself as `cowork_local.*` or through relative imports. This checkout's folder is not named `cowork_local`, so:
- **Running the app:** `python -m cowork_local` works only from the parent of a folder literally named `cowork_local`. On Windows, `install.bat` (once; add `--dev` for test deps, `--system` to skip the venv) builds a venv under `%LOCALAPPDATA%\CoworkLocal` and creates a junction `%LOCALAPPDATA%\CoworkLocal\launcher\<key>\cowork_local` pointing at this checkout. After that, use `run.bat`. The MS365 MCP server is spawned as `python -m cowork_local.mcp_servers.ms365_server`, so the junction is needed for subprocesses too.
- **Never create a `.venv` inside the repo.** The quality gates walk the whole tree.
- **Tests:** the root `conftest.py` and `tests/conftest.py` bind `sys.modules["cowork_local"]` to this checkout, so pytest works whatever the folder is named. Tests use both import styles: top-level (`from providers.base import ...`) and qualified (`from cowork_local.core... import ...`). Characterization tests that spawn `python -c "from cowork_local..."` subprocesses still need a real `cowork_local` directory on `PYTHONPATH`. CI checks out into `cowork_local/` for this reason.
## Commands
```bash
python -m pip install -r requirements.txt # single requirements file (includes pytest)
python -m pytest tests -q # full suite (what CI runs)
python -m pytest tests/unit/test_schedule_calculator.py -q # one file
python -m pytest tests/unit/test_schedule_calculator.py -k name -q # one test
python -m pytest tests/e2e/test_smoke.py -v # release smoke test
python scripts/run_quality_gate.py # all CASAN gates + pytest
python scripts/run_quality_gate.py --skip-tests # static gates only
python scripts/check_imports.py # domain/ + application/ must not import PySide6/PyQt/ui/app
python scripts/audit_security.py # no plaintext credentials (CI also runs --self-test)
python scripts/check_loc.py # <= 400 lines per production file
python scripts/check_orphan_modules.py # every production module must be reachable by import
```
Widget tests run headless with `QT_QPA_PLATFORM=offscreen`. `tools/check_*.py` are standalone offscreen smoke checkers against a real `MainWindow` built on a copy of `~/.cowork_local` (for example `python tools/check_nav.py`). Some of them still import private names re-exported from `app.py`, so keep those re-exports. Set `COWORK_PERF_TRACE=1` to log timing spans from `performance.py`.
## Architecture
Target design is 4-tier Clean Architecture (`docs/architecture/ADR-001-layered-architecture.md`):
- `domain/`: pure stdlib entities, frozen request snapshots (`ConversationExecutionRequest`), `AgentEvent`, tool/provider descriptors, `ScheduleCalculator`.
- `application/`: pure-Python use-case services (conversations, model_routing, scheduling, workspaces, monitoring, workflows). **No Qt.** Must run headless.
- `infrastructure/`: adapters: config (`JsonConfigRepository` over `AtomicJsonFile`), OS-keyring `SecretStore`, providers, MCP (`McpToolSourceManager`), sandbox, filesystem, telemetry, and Qt bridges (`infrastructure/qt`).
- `presentation/`: PySide6 widgets by feature (`shell`, `chat`, `co4e`, `dashboard`, `scheduling`, `workspace`, `monitoring`, `graph`, `settings`, ...). Widgets call `application/` services. They don't touch persistence or run LLM calls on the GUI thread. Agent work runs in worker threads and reaches the UI as `AgentEvent`s through Qt signal bridges.
The refactor is **incomplete**. Legacy top-level packages are still live and imported by the app:
- `ui/`: older tabs such as `cowork_tab`, `workspace_tab`, `monitoring_tab`, `settings_dialog`, `chat_panel`, and `co4e_*`.
- `core/`: agents, the Co4E flow runner, task scheduler, skills, tools, audit/usage tracking, routing.
- `providers/`: `base`, `anthropic`, `openai_compat`, `factory`.
- `security/`: validators, command risk classifier.
- Root modules: `config.py`, `state.py`.
`docs/architecture/dormant-code.md` lists deprecated pieces, such as `state.py::active_project_id` and the monolithic `core/tools.py`. New layers must not import dormant code.
Wiring:
- `__main__.py` → `app.run()`.
- `presentation/shell/bootstrap.py` is the **Composition Root**. It builds `AppContext` (`state.py`) around `JsonConfigRepository` plus `KeyringAdapter`, falling back to the config file when no keyring exists.
- `run()` then seeds the built-in skills (`skill_library/*.skill`) and the Co4E flows, applies the theme, and opens `presentation/shell/main_window.MainWindow`.
- Pages are registered in `presentation/shell/page_registry.py`.
Other cross-cutting pieces:
- `i18n/`: `tr(key, **kw)` with en/ja/vi (default `vi`). Long-lived widgets must use `bind_text(...)` or `on_language_changed(...)` so a language switch re-applies their text. Transient dialogs just call `tr()` at construction.
- `theme/`: palettes and QSS. Use theme tokens, not hard-coded colors.
- `mcp_servers/`: bundled MCP servers (MS365, project_context).
- `agent/`: a markdown instruction library for UI/UX bug-fix agents, not runtime code.
Step-by-step recipes for adding a provider, a tool or MCP server, or a screen are in `docs/governance/contributor-recipes.md`.
## Rules enforced by gates and review
- **400-line limit** for every production file. This covers `domain`, `application`, `infrastructure`, `presentation`, `ui`, `core`, `providers`, `security`, `mcp_servers`, `i18n`, `theme`, and root `.py` files. Legacy oversized files have per-file caps in `scripts/check_loc.py` that may only go down. Split files; never raise a cap.
- **No orphan modules.** `check_orphan_modules.py` has an `ALLOWLIST` that may only shrink. Wire up or delete a module instead of allowlisting it.
- **Secrets** belong in the OS keyring, never in `config.json` or the code. `.env.example` is not auto-loaded. Tests never use live provider credentials (use `tests/fakes/`: `FakeProvider`, `FakeToolExecutor`, `FakeToolPolicyGateway`, `FakeConfigRepository`/`FakeSecretStore`, `FakeClock`, ...).
- **Test layout:** `tests/unit` (fast, no I/O), `tests/contracts`, `tests/integration` (Qt offscreen), `tests/characterization` (pinned legacy behavior during refactors), `tests/e2e`, `tests/ui`, plus flat `tests/test_*.py`.
- **Startup housekeeping** (seeding, pruning) is wrapped in `try/except` and must never block app launch.
- **Comments:** the ADR asks for English comments. Existing code mixes English and Vietnamese docstrings and comments, so match the surrounding file.
## Git workflow
- Branches: `feat/`, `fix/`, `test/`, `docs/`, `perf/`, `refactor/<short-desc>`. Core AI work uses `core-ai/<task-id>-<name>`.
- Commits use Conventional Commit prefixes (`feat:`, `fix:`, `test:`, `docs:`, `refactor:`, `perf:`, `chore:`).
- One logical change per PR, using `.gitea/PULL_REQUEST_TEMPLATE.md`. The remote is a Gitea instance, and CI (`.gitea/workflows/ci.yaml`, Python 3.11) runs on PRs to `main`.
- Critical areas listed in `SECURITY.md` get extra review.
Binary file not shown.
+1 -1
View File
@@ -15,7 +15,7 @@ network control, permission management, audit log). No login required —
starts directly with full admin access. starts directly with full admin access.
""" """
__version__ = "2.26.0" __version__ = "0.0.1"
# Internal/technical name — config dir (~/.cowork_local), QSettings org keys, # Internal/technical name — config dir (~/.cowork_local), QSettings org keys,
# packaging scripts and docs still use this; do NOT rebrand it. # packaging scripts and docs still use this; do NOT rebrand it.
APP_NAME = "Cowork Local" APP_NAME = "Cowork Local"
+25
View File
@@ -111,6 +111,28 @@ Ba luật:
chúng tự dựng `QLabel` bên trong, không giữ tham chiếu nào để áp lại. Truyền chúng tự dựng `QLabel` bên trong, không giữ tham chiếu nào để áp lại. Truyền
`bind_text(QLabel(), key)` hoặc truyền **khoá** thay vì chuỗi đã dịch. `bind_text(QLabel(), key)` hoặc truyền **khoá** thay vì chuỗi đã dịch.
### 2.0b. Nút do CHÍNH Qt vẽ chữ — `ui/dialog_buttons.py`
`tr()` không với tới được nhãn nút của mấy widget dựng sẵn: Qt lấy chữ từ bảng dịch của
riêng nó, mà ứng dụng không cài `QTranslator` nào (bản PySide6 đang dùng cũng không đóng
gói file `qtbase_*.qm` nào để cài). Kết quả: **luôn là tiếng Anh ở cả ba ngôn ngữ.**
| Không dùng | Dùng thay |
| --- | --- |
| `QDialogButtonBox(Save \| Cancel)` | `dialog_buttons(Save \| Cancel)` |
| `QMessageBox.question(...) == QMessageBox.Yes` | `confirm(parent, title, body)` |
| `QInputDialog.getText / getMultiLineText / getItem` | `ask_text` / `ask_multiline` / `ask_item` |
Muốn một nút mang chữ riêng thì truyền khoá vào `dialog_buttons`, **không** `setText(tr(...))`
sau khi dựng — lần đổi ngôn ngữ kế tiếp, ràng buộc sẽ áp lại khoá mặc định và xoá mất chữ đó:
```python
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
ok="schedtask.ai_confirm")
```
Ba cổng trong `tests/ui/test_i18n_khong_hardcode_chu.py` canh việc này.
### 2.1. Widget sống lâu ### 2.1. Widget sống lâu
Ví dụ: Ví dụ:
@@ -338,6 +360,9 @@ Trước khi hoàn thành bản vá i18n, phải kiểm tra:
* [ ] Không còn chuỗi hardcode mới trong bản vá? * [ ] Không còn chuỗi hardcode mới trong bản vá?
* [ ] Nút hộp thoại đi qua `ui/dialog_buttons.py` (mục 2.0b), không dựng
`QDialogButtonBox` / `QMessageBox.question` / `QInputDialog.get*` trực tiếp?
* [ ] Layout vẫn đúng với **chuỗi dài nhất** trong 3 ngôn ngữ? * [ ] Layout vẫn đúng với **chuỗi dài nhất** trong 3 ngôn ngữ?
* [ ] Không dùng `len()` để tính chiều rộng text? * [ ] Không dùng `len()` để tính chiều rộng text?
@@ -74,6 +74,14 @@ class CoreToolRuntime:
đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool. đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool.
""" """
self._output_dir = Path(output_dir) self._output_dir = Path(output_dir)
# Every sandboxed tool (run_command included) gets this as its cwd —
# it must exist BEFORE the first tool call, same as the older
# run_cowork() (core/chat_agent.py) already does at its output_dir.
# Without this, a per-turn ".turns/<id>" folder that was never created
# makes run_command's subprocess.Popen(cwd=...) fail immediately with
# WinError 267 ("directory name is invalid") before the command even
# starts — no network, no output, just an opaque OS error.
self._output_dir.mkdir(parents=True, exist_ok=True)
self._title = title self._title = title
self._extra_tools = list(extra_tools or ()) self._extra_tools = list(extra_tools or ())
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools} self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
+5
View File
@@ -0,0 +1,5 @@
"""Application services for the "Block network" switch (Sandbox Security Layer)."""
from .network_guard import NetworkBlockedError, bind, ensure_allowed, is_blocked, refusal
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
+61
View File
@@ -0,0 +1,61 @@
"""One gate for every outbound connection the app makes on its own.
The "Block network" switch (``agent_security.block_network``) used to be read
only where agent tools run, so Microsoft 365, Teams, connector test buttons,
scheduled task scripts, pip auto-installs and HTML previews still reached the
internet while Monitoring said "Network: blocked". Each of those now asks
this module first.
The AI provider path (chat, model list, model test) deliberately does NOT go
through here: with the switch on the user still talks to the model, but the
app fetches nothing else on the model's or its own behalf.
The module holds a *reader*, not a copy of the flag: the Composition Root
binds it to the live config once (``presentation/shell/bootstrap.py``), so a
change saved in Settings takes effect on the very next call. Nothing bound
(unit tests, helper subprocesses) means "not blocked", the pre-switch default.
"""
from __future__ import annotations
import threading
from typing import Callable, Optional
_lock = threading.Lock()
_reader: Optional[Callable[[], bool]] = None
class NetworkBlockedError(PermissionError):
"""Raised by :func:`ensure_allowed` while the switch is on."""
def bind(reader: Optional[Callable[[], bool]]) -> None:
"""Install the callable that says whether the network is blocked right now."""
global _reader
with _lock:
_reader = reader
def is_blocked() -> bool:
"""True while "Block network" is on. A failing reader counts as blocked."""
reader = _reader
if reader is None:
return False
try:
return bool(reader())
except Exception: # noqa: BLE001 - fail closed: an unreadable switch must not open the network
return True
def refusal(purpose: str) -> str:
"""The message shown (to the user or the model) when ``purpose`` is refused."""
return (f"{purpose}: network access is blocked by the Sandbox Security Layer "
"(\"Block network\" is on in Settings). Only the AI provider may be reached.")
def ensure_allowed(purpose: str) -> None:
"""Raise :class:`NetworkBlockedError` if ``purpose`` may not go online now."""
if is_blocked():
raise NetworkBlockedError(refusal(purpose))
__all__ = ["NetworkBlockedError", "bind", "ensure_allowed", "is_blocked", "refusal"]
@@ -21,9 +21,14 @@ def pptx_available() -> bool:
try: try:
from cowork_local.core.deps import ensure_module from cowork_local.core.deps import ensure_module
_PPTX_READY = ensure_module("pptx", "python-pptx") is not None ready = ensure_module("pptx", "python-pptx") is not None
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
_PPTX_READY = False ready = False
from ..network import network_guard
if ready or not network_guard.is_blocked():
_PPTX_READY = ready # a refusal under "Block network" is retried later
return ready
return _PPTX_READY return _PPTX_READY
+8 -4
View File
@@ -100,11 +100,15 @@ DEFAULT_CONFIG: Dict[str, Any] = {
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU% "resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB) "resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB) "resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands # "Block network": every outbound connection except the AI provider is
# refused (application/network/network_guard.py), and agent shell
# commands / task scripts run in a network-less AppContainer.
# OFF on first launch — the user turns it on in Settings.
"block_network": False,
# Allow the agent's fetch_url tool to read web pages / online documents / # Allow the agent's fetch_url tool to read web pages / online documents /
# SharePoint-OneDrive share links. SEPARATE from block_network (that only # SharePoint-OneDrive share links. Its own toggle — reading a URL for info
# sandboxes agent-run shell commands) — reading a URL for info is safe and # is safe and useful, so this defaults ON — but block_network outranks it:
# useful, so this defaults ON. Toggle in Settings → Security. # with the network blocked the tool is refused either way.
"allow_url_fetch": True, "allow_url_fetch": True,
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD "sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
+9
View File
@@ -527,6 +527,15 @@ def run_cowork(
preview = {"kind": "info", "title": name, "text": str(args)} preview = {"kind": "info", "title": name, "text": str(args)}
emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args, emit({"type": "tool_proposed", "id": tc_id, "name": name, "args": args,
"preview": preview}) "preview": preview})
if ctx.block_network and not name.startswith("ms365_local__"):
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
emit({"type": "tool_result", "id": tc_id, "name": name,
"ok": False, "output": result["output"]})
messages.append({"role": "tool", "tool_call_id": tc_id, "name": name,
"content": result["output"]})
continue
# R05-T04: MCP/connector tools used to run with NO permission # R05-T04: MCP/connector tools used to run with NO permission
# check at all — this is what closes that gap. Same policy, # check at all — this is what closes that gap. Same policy,
# same gate object as the built-in tools below. # same gate object as the built-in tools below.
+114
View File
@@ -0,0 +1,114 @@
"""Mirror a OneDrive/SharePoint folder to/from a local directory (DF-007).
This is deliberately NOT a general sync engine: every existing tool
(``run_command``, ``read_file``, ``write_file``...) operates on a real local
``Path`` (``Project.output_dir`` — see ``core/projects.py::Project.workspace_dir``),
and that contract does not change here. A cloud-backed project's
``output_dir`` still points at a real local folder; this module only knows how
to pull that folder's content down from Graph once, and push it back up once,
both on explicit user action (a button click) — there is no background
watcher, no continuous sync, no delete propagation, and no conflict
resolution beyond "whichever side ran last wins" for a given file. See the
DF-007 plan for why: OneDrive/SharePoint sync-client detection is unreliable,
so a local mirror + manual sync is the only predictable option that does not
touch the sandboxed command/file tools.
"""
from __future__ import annotations
import os
from dataclasses import dataclass, field
from pathlib import Path
from typing import Dict, List
from . import ms365_graph as graph
@dataclass
class SyncReport:
"""Kết quả một lượt tải xuống/đẩy lên — hiển thị cho người dùng sau khi chạy."""
transferred: int = 0
skipped_too_large: List[str] = field(default_factory=list)
errors: List[str] = field(default_factory=list)
def _list_children(token: str, cloud_source: Dict[str, str], remote_path: str) -> List[dict]:
provider = cloud_source.get("provider")
if provider == "sharepoint":
return graph.list_sharepoint_files(token, cloud_source["site_id"], remote_path)
return graph.list_onedrive_files(token, remote_path)
def _download_file(token: str, cloud_source: Dict[str, str], remote_path: str) -> bytes:
if cloud_source.get("provider") == "sharepoint":
return graph.download_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path)
return graph.download_onedrive_file_bytes(token, remote_path)
def _upload_file(token: str, cloud_source: Dict[str, str], remote_path: str, data: bytes) -> None:
if cloud_source.get("provider") == "sharepoint":
graph.upload_sharepoint_file_bytes(token, cloud_source["site_id"], remote_path, data)
else:
graph.upload_onedrive_file_bytes(token, remote_path, data)
def download_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
"""Tải toàn bộ cây thư mục ``cloud_source['remote_path']`` xuống ``local_dir``,
giữ nguyên cấu trúc thư mục con. Ghi đè file local nếu đã tồn tại (một
chiều: cloud thắng). Không xoá file local nào không còn ở phía cloud."""
report = SyncReport()
root_remote = cloud_source.get("remote_path", "")
local_dir.mkdir(parents=True, exist_ok=True)
def _walk(remote_path: str, local_sub: Path) -> None:
try:
children = _list_children(token, cloud_source, remote_path)
except graph.Ms365GraphError as exc:
report.errors.append(f"{remote_path or '/'}: {exc}")
return
for item in children:
name = item.get("name", "")
if not name:
continue
child_remote = f"{remote_path}/{name}" if remote_path else name
child_local = local_sub / name
if "folder" in item:
child_local.mkdir(parents=True, exist_ok=True)
_walk(child_remote, child_local)
else:
try:
data = _download_file(token, cloud_source, child_remote)
child_local.write_bytes(data)
report.transferred += 1
except graph.Ms365GraphError as exc:
report.errors.append(f"{child_remote}: {exc}")
_walk(root_remote, local_dir)
return report
def upload_folder(token: str, cloud_source: Dict[str, str], local_dir: Path) -> SyncReport:
"""Đẩy mọi file dưới ``local_dir`` lên đúng đường dẫn tương ứng phía cloud
(tạo mới hoặc ghi đè). Một chiều: local thắng cho từng file được duyệt qua.
Không xoá file cloud nào đã bị xoá ở local, không phát hiện xung đột."""
report = SyncReport()
root_remote = cloud_source.get("remote_path", "")
local_dir = Path(local_dir)
for dirpath, _dirnames, filenames in os.walk(local_dir):
rel_dir = Path(dirpath).relative_to(local_dir)
for fname in filenames:
local_file = Path(dirpath) / fname
rel_parts = [] if str(rel_dir) == "." else list(rel_dir.parts)
rel_parts.append(fname)
child_remote = "/".join(([root_remote] if root_remote else []) + rel_parts)
try:
data = local_file.read_bytes()
_upload_file(token, cloud_source, child_remote, data)
report.transferred += 1
except graph.Ms365GraphError as exc:
if "too large" in str(exc):
report.skipped_too_large.append(child_remote)
else:
report.errors.append(f"{child_remote}: {exc}")
except OSError as exc:
report.errors.append(f"{child_remote}: {exc}")
return report
+5
View File
@@ -327,6 +327,11 @@ def run_code(
else: else:
emit({"type": "tool_start", "id": tc_id, "name": name}) emit({"type": "tool_start", "id": tc_id, "name": name})
if is_extra and extra_executor is not None: if is_extra and extra_executor is not None:
if ctx.block_network and not name.startswith("ms365_local__"):
result = {"ok": False, "output": (
f"{name}: network access is blocked by the Sandbox Security Layer "
'("Block network for agent-run commands" is on in Settings).')}
else:
result = extra_executor(name, args) result = extra_executor(name, args)
else: else:
def on_output(line: str, _id=tc_id, _name=name) -> None: def on_output(line: str, _id=tc_id, _name=name) -> None:
+20 -1
View File
@@ -91,6 +91,7 @@ def run_cancellable(
on_output: Optional[Callable[[str], None]] = None, on_output: Optional[Callable[[str], None]] = None,
env: Optional[Dict[str, str]] = None, env: Optional[Dict[str, str]] = None,
limits: Optional[Dict[str, float]] = None, limits: Optional[Dict[str, float]] = None,
isolate_network: bool = False,
) -> Tuple[Optional[int], str, bool, bool, bool]: ) -> Tuple[Optional[int], str, bool, bool, bool]:
"""Run a subprocess so the Stop button can actually interrupt it. """Run a subprocess so the Stop button can actually interrupt it.
@@ -117,17 +118,27 @@ def run_cancellable(
a failure to create/assign the job just means the existing taskkill a failure to create/assign the job just means the existing taskkill
fallback is used, same as before this was added. fallback is used, same as before this was added.
``isolate_network`` runs ``args`` as a shell command that the OS keeps
off the network (see ``infrastructure/sandbox/network_isolation.py``);
if that isolation cannot be set up the command is NOT run.
Returns ``(returncode, combined_output, cancelled, timed_out, Returns ``(returncode, combined_output, cancelled, timed_out,
resource_exceeded)``; on a failure to even launch the process, resource_exceeded)``; on a failure to even launch the process,
``returncode`` is ``None`` and the output holds the launch error.""" ``returncode`` is ``None`` and the output holds the launch error."""
cancel = cancel or (lambda: False) cancel = cancel or (lambda: False)
popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True} popen_kwargs = {} if sys.platform == "win32" else {"start_new_session": True}
try: try:
if isolate_network:
from ..infrastructure.sandbox.network_isolation import spawn_without_network
command = args if isinstance(args, str) else subprocess.list2cmdline(args)
proc = spawn_without_network(command, cwd, env)
else:
proc = subprocess.Popen( proc = subprocess.Popen(
args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE, args, shell=shell, cwd=cwd, stdout=subprocess.PIPE, stderr=subprocess.PIPE,
text=True, bufsize=1, env=env, **popen_kwargs, text=True, bufsize=1, env=env, **popen_kwargs,
) )
except OSError as exc: except (OSError, RuntimeError) as exc: # RuntimeError: NetworkIsolationUnavailable
return None, str(exc), False, False, False return None, str(exc), False, False, False
with _active_pids_lock: with _active_pids_lock:
@@ -266,6 +277,10 @@ def ensure_module(module: str, package: str | None = None):
pkg = package or module pkg = package or module
if pkg in _FAILED or not _can_pip(): if pkg in _FAILED or not _can_pip():
return None return None
from ..application.network import network_guard
if network_guard.is_blocked():
return None # not cached in _FAILED: retried once the network is back
ok, _ = pip_install(pkg) ok, _ = pip_install(pkg)
if not ok: if not ok:
_FAILED.add(pkg) _FAILED.add(pkg)
@@ -339,6 +354,10 @@ def pip_install(package: str, cancel: Optional[CancelFn] = None,
name) is NOT retried, since repeating it would just waste time.""" name) is NOT retried, since repeating it would just waste time."""
if not _can_pip(): if not _can_pip():
return False, "This packaged build can't install packages at runtime." return False, "This packaged build can't install packages at runtime."
from ..application.network import network_guard
if network_guard.is_blocked():
return False, network_guard.refusal(f"pip install {package}")
exe = python or sys.executable exe = python or sys.executable
attempt = 0 attempt = 0
while True: while True:
+19 -8
View File
@@ -94,17 +94,28 @@ def find_input_files(folder: Path, exts: set[str] | None = None,
capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count capped at ``max_files`` (0 = unlimited), ``total_matched`` is the count
before that cap, so a caller can report how many were skipped.""" before that cap, so a caller can report how many were skipped."""
exts = exts or INPUT_EXTS exts = exts or INPUT_EXTS
# Do not sort an unbounded recursive tree merely to return a small prefix.
# The caller receives a stable lexical order for the bounded result, while
# traversal stops as soon as the configured file budget is reached.
files: list[Path] = []
total = 0
try: try:
matched = sorted( for f in folder.rglob("*"):
f for f in folder.rglob("*") if not f.is_file():
if f.is_file() continue
and not any(part.startswith(".") for part in f.relative_to(folder).parts) try:
and f.suffix.lower() in exts relative = f.relative_to(folder)
) except ValueError:
continue
if any(part.startswith(".") for part in relative.parts) or f.suffix.lower() not in exts:
continue
total += 1
if max_files <= 0 or len(files) < max_files:
files.append(f)
except OSError: except OSError:
return [], 0 return [], 0
files = matched if max_files <= 0 else matched[:max_files] files.sort(key=lambda p: str(p).lower())
return files, len(matched) return files, total
def find_soffice() -> str | None: def find_soffice() -> str | None:
+6
View File
@@ -132,8 +132,11 @@ class RestApiConnector:
def call(self, args: Dict[str, Any]) -> Dict[str, Any]: def call(self, args: Dict[str, Any]) -> Dict[str, Any]:
"""Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ.""" """Gọi API theo tham số model đưa ra, đi qua lớp TLS có ghim chứng chỉ nội bộ."""
from ..application.network import network_guard
from .tls_trust import request_any_method as tls_request from .tls_trust import request_any_method as tls_request
if network_guard.is_blocked():
return {"ok": False, "output": network_guard.refusal(self.display_name)}
method = str(args.get("method", "GET")).upper() method = str(args.get("method", "GET")).upper()
path = str(args.get("path", "")).lstrip("/") path = str(args.get("path", "")).lstrip("/")
url = urljoin(self.base_url, path) url = urljoin(self.base_url, path)
@@ -164,10 +167,13 @@ class RestApiConnector:
def test_connection(self) -> Tuple[bool, str]: def test_connection(self) -> Tuple[bool, str]:
"""Thử kết nối tới endpoint; trả về (thành công, thông điệp).""" """Thử kết nối tới endpoint; trả về (thành công, thông điệp)."""
from ..application.network import network_guard
from .tls_trust import request as tls_request from .tls_trust import request as tls_request
if not self.base_url.strip("/"): if not self.base_url.strip("/"):
return False, "No base URL configured." return False, "No base URL configured."
if network_guard.is_blocked():
return False, network_guard.refusal(self.display_name)
headers = {} headers = {}
if self.api_key: if self.api_key:
headers[self.auth_header] = ( headers[self.auth_header] = (
+29
View File
@@ -16,6 +16,17 @@ from datetime import datetime
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List from typing import Any, Dict, List
from ..performance import span
_LIST_CACHE: dict[tuple[str, str, int], List[Dict[str, Any]]] = {}
def _invalidate_history_cache(directory: Path) -> None:
prefix = str(Path(directory).resolve())
for key in list(_LIST_CACHE):
if key[0] == prefix:
_LIST_CACHE.pop(key, None)
def new_session_id() -> str: def new_session_id() -> str:
"""Id phiên mới theo mốc thời gian, chính xác tới mili giây.""" """Id phiên mới theo mốc thời gian, chính xác tới mili giây."""
@@ -78,6 +89,7 @@ def save_conversation(
# R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py. # R06-T02: atomic write - see infrastructure/persistence/json/atomic_write.py.
from ..infrastructure.persistence.json.atomic_write import write_json from ..infrastructure.persistence.json.atomic_write import write_json
write_json(path, payload) write_json(path, payload)
_invalidate_history_cache(directory)
return path return path
@@ -85,6 +97,7 @@ def delete_conversation(path) -> None:
"""Xoá file hội thoại; không có thì bỏ qua.""" """Xoá file hội thoại; không có thì bỏ qua."""
try: try:
Path(path).unlink() Path(path).unlink()
_invalidate_history_cache(Path(path).parent)
except OSError: except OSError:
pass pass
@@ -96,6 +109,7 @@ def rename_conversation(path, new_title: str) -> None:
data = load_conversation(path) data = load_conversation(path)
data["title"] = new_title data["title"] = new_title
write_json(Path(path), data) write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def set_pinned(path, pinned: bool) -> None: def set_pinned(path, pinned: bool) -> None:
@@ -105,6 +119,7 @@ def set_pinned(path, pinned: bool) -> None:
data = load_conversation(path) data = load_conversation(path)
data["pinned"] = bool(pinned) data["pinned"] = bool(pinned)
write_json(Path(path), data) write_json(Path(path), data)
_invalidate_history_cache(Path(path).parent)
def load_conversation(path: Path) -> Dict[str, Any]: def load_conversation(path: Path) -> Dict[str, Any]:
@@ -197,7 +212,15 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
if not directory or not directory.exists(): if not directory or not directory.exists():
return [] return []
q = (query or "").strip().lower() q = (query or "").strip().lower()
try:
cache_key = (str(directory.resolve()), q, directory.stat().st_mtime_ns)
except OSError:
return []
cached = _LIST_CACHE.get(cache_key)
if cached is not None:
return [dict(item) for item in cached]
items: List[Dict[str, Any]] = [] items: List[Dict[str, Any]] = []
with span("history.list", query=bool(q)):
for path in directory.glob("*.json"): for path in directory.glob("*.json"):
try: try:
data = json.loads(path.read_text(encoding="utf-8")) data = json.loads(path.read_text(encoding="utf-8"))
@@ -221,4 +244,10 @@ def list_conversations(directory: Optional[Path] = None, query: str = "") -> Lis
}) })
# pinned first, then most recent # pinned first, then most recent
items.sort(key=lambda d: (not d["pinned"], -d["mtime"])) items.sort(key=lambda d: (not d["pinned"], -d["mtime"]))
_LIST_CACHE[cache_key] = [dict(item) for item in items]
# Keep this bounded; old directory signatures become unreachable after a
# write and should not grow process memory forever.
if len(_LIST_CACHE) > 256:
for old in list(_LIST_CACHE)[:64]:
_LIST_CACHE.pop(old, None)
return items return items
+2
View File
@@ -85,8 +85,10 @@ def get_issue_by_url(config: Dict[str, Any] | None, url: str) -> str:
def _get(config: Dict[str, Any], path: str, params: dict = None): def _get(config: Dict[str, Any], path: str, params: dict = None):
"""Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ.""" """Gọi Jira REST API bằng xác thực cơ bản, qua lớp TLS có ghim chứng chỉ nội bộ."""
from ..application.network import network_guard
from . import tls_trust from . import tls_trust
network_guard.ensure_allowed("Jira")
c = _conf(config) c = _conf(config)
url = c["base_url"].rstrip("/") + path url = c["base_url"].rstrip("/") + path
# Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) — # Same TLS auto-recovery the LLM provider calls get (core/tls_trust.py) —
+6
View File
@@ -147,6 +147,12 @@ def fetch_link_preview(url: str) -> str:
return "" return ""
if not re.match(r"^https?://", url, re.IGNORECASE): if not re.match(r"^https?://", url, re.IGNORECASE):
return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)" return f"[Link: {url}] (not a fetchable http(s) URL — referenced by address only)"
# Every caller (fetch_url, task link attachments, ...) passes through here,
# so this one check covers the paths that never saw a ToolContext.
from ..application.network import network_guard
if network_guard.is_blocked():
return f"[Link: {url}] (not fetched — {network_guard.refusal('link fetch')})"
# SharePoint / OneDrive share links are rewritten to their direct-download # SharePoint / OneDrive share links are rewritten to their direct-download
# form so the shared FILE itself is fetched and parsed (like an attachment), # form so the shared FILE itself is fetched and parsed (like an attachment),
# not the share page's HTML shell. # not the share page's HTML shell.
+12 -1
View File
@@ -88,7 +88,14 @@ class McpServerConnection:
def start(self, timeout: float = 15.0) -> None: def start(self, timeout: float = 15.0) -> None:
"""Spawn the server subprocess and complete the MCP handshake. """Spawn the server subprocess and complete the MCP handshake.
Raises :class:`McpServerError` on failure (bad command, the server Raises :class:`McpServerError` on failure (bad command, the server
crashed on startup, the handshake timed out, ...).""" crashed on startup, the handshake timed out, ...).
Refused while "Block network" is on: a server process is free to open
any socket it likes, so the only safe server is one never started."""
from ..application.network import network_guard
if network_guard.is_blocked():
raise McpServerError(network_guard.refusal(f"MCP server '{self.name}'"))
self._thread = threading.Thread(target=self._run_loop, daemon=True) self._thread = threading.Thread(target=self._run_loop, daemon=True)
self._thread.start() self._thread.start()
if not self._ready.wait(timeout): if not self._ready.wait(timeout):
@@ -174,6 +181,10 @@ class McpServerConnection:
def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]: def call_tool(self, qualified_name: str, args: Dict[str, Any]) -> Dict[str, Any]:
"""``extra_executor``-shaped result: ``{"ok": bool, "output": str}``.""" """``extra_executor``-shaped result: ``{"ok": bool, "output": str}``."""
from ..application.network import network_guard
if network_guard.is_blocked():
return {"ok": False, "output": network_guard.refusal(f"MCP server '{self.name}'")}
tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name tool_name = qualified_name.split(_SEP, 1)[1] if _SEP in qualified_name else qualified_name
try: try:
result = self._run_coro(self._session.call_tool(tool_name, args or {})) result = self._run_coro(self._session.call_tool(tool_name, args or {}))
+29
View File
@@ -137,8 +137,34 @@ def _app(tenant_id: str, client_id: str):
return app, cache return app, cache
def _cached_account_offline() -> Optional[dict]:
"""First account in the saved token cache, read without any MSAL network setup."""
try:
import msal
accounts = _load_cache().find(msal.TokenCache.CredentialType.ACCOUNT)
except Exception: # noqa: BLE001 - no msal / unreadable cache = not signed in
return None
return accounts[0] if accounts else None
def _ensure_network(action: str) -> None:
"""Turn a "Block network" refusal into the error type callers already handle."""
from ..application.network import network_guard
if network_guard.is_blocked():
raise Ms365AuthError(network_guard.refusal(action))
def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]: def signed_in_account(tenant_id: str, client_id: str) -> Optional[dict]:
"""The cached account, if any — a local cache lookup, no network call.""" """The cached account, if any — a local cache lookup, no network call."""
from ..application.network import network_guard
if network_guard.is_blocked():
# Building the MSAL app fetches the tenant's OpenID configuration, so
# read the token cache directly instead: the UI still sees who is
# signed in without the app reaching login.microsoftonline.com.
return _cached_account_offline()
try: try:
app, _cache = _app(tenant_id, client_id) app, _cache = _app(tenant_id, client_id)
except Ms365AuthError: except Ms365AuthError:
@@ -156,6 +182,7 @@ def sign_in_device_code(tenant_id: str, client_id: str, on_code: Callable[[dict]
``verification_uri_complete`` (URL with the code pre-filled, when the tenant ``verification_uri_complete`` (URL with the code pre-filled, when the tenant
returns it) and ``message`` (the full human-readable instruction). Returns returns it) and ``message`` (the full human-readable instruction). Returns
the MSAL token result dict; raises Ms365AuthError on failure/timeout.""" the MSAL token result dict; raises Ms365AuthError on failure/timeout."""
_ensure_network("Microsoft 365 sign-in")
app, cache = _app(tenant_id, client_id) app, cache = _app(tenant_id, client_id)
flow = app.initiate_device_flow(scopes=SCOPES) flow = app.initiate_device_flow(scopes=SCOPES)
if "user_code" not in flow: if "user_code" not in flow:
@@ -183,6 +210,7 @@ def get_access_token(tenant_id: str, client_id: str) -> str:
"""Silently reuse the cached sign-in. Raises Ms365AuthError when there is """Silently reuse the cached sign-in. Raises Ms365AuthError when there is
no valid session — the caller (a Graph call) should surface that as a no valid session — the caller (a Graph call) should surface that as a
normal tool failure telling the user to sign in again from Settings.""" normal tool failure telling the user to sign in again from Settings."""
_ensure_network("Microsoft 365")
app, cache = _app(tenant_id, client_id) app, cache = _app(tenant_id, client_id)
accounts = app.get_accounts() accounts = app.get_accounts()
if not accounts: if not accounts:
@@ -228,6 +256,7 @@ def sign_out_default(config=None) -> None:
def sign_out(tenant_id: str, client_id: str) -> None: def sign_out(tenant_id: str, client_id: str) -> None:
"""Đăng xuất và xoá token của một tenant/client khỏi kho.""" """Đăng xuất và xoá token của một tenant/client khỏi kho."""
try: try:
_ensure_network("Microsoft 365 sign-out") # chặn mạng: chỉ xoá kho token bên dưới
app, cache = _app(tenant_id, client_id) app, cache = _app(tenant_id, client_id)
for acc in app.get_accounts(): for acc in app.get_accounts():
app.remove_account(acc) app.remove_account(acc)
+56
View File
@@ -43,6 +43,10 @@ def _request(method: str, url: str, token: str, **kwargs) -> requests.Response:
"""Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP """Gọi Graph API, tự ghép ``GRAPH_BASE`` cho đường dẫn tương đối và đổi lỗi HTTP
thành :class:`Ms365GraphError` kèm thông điệp đọc được. thành :class:`Ms365GraphError` kèm thông điệp đọc được.
""" """
from ..application.network import network_guard
if network_guard.is_blocked():
raise Ms365GraphError(network_guard.refusal("Microsoft 365 (Graph)"))
if not url.startswith("http"): if not url.startswith("http"):
url = f"{GRAPH_BASE}{url}" url = f"{GRAPH_BASE}{url}"
headers = _headers(token, kwargs.pop("headers", None)) headers = _headers(token, kwargs.pop("headers", None))
@@ -196,6 +200,40 @@ def write_onedrive_file(token: str, path: str, content: str) -> dict:
return resp.json() return resp.json()
# Graph's "simple upload" (a single PUT to .../content) is documented to only
# support items up to 4 MiB; anything larger needs a chunked "upload session"
# (createUploadSession + PUT-per-range), which this module does not implement
# (see DF-007 cloud workspace picker — v1 explicitly skips large files rather
# than silently truncating or corrupting them).
MAX_SIMPLE_UPLOAD_BYTES = 4 * 1024 * 1024
def _check_upload_size(data: bytes) -> None:
if len(data) > MAX_SIMPLE_UPLOAD_BYTES:
raise Ms365GraphError(
f"File too large for simple upload ({len(data)} bytes > "
f"{MAX_SIMPLE_UPLOAD_BYTES} bytes) — chunked upload sessions are not "
"implemented yet."
)
def download_onedrive_file_bytes(token: str, path: str) -> bytes:
"""Đọc RAW BYTES một tệp OneDrive (không ép UTF-8/không cắt) — dùng cho
mirror thư mục cloud xuống local, khác với :func:`read_onedrive_file` vốn
chỉ dành cho việc đọc nội dung văn bản vào ngữ cảnh chat."""
resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token)
return resp.content
def upload_onedrive_file_bytes(token: str, path: str, data: bytes) -> dict:
"""Ghi RAW BYTES vào một tệp OneDrive (tạo mới hoặc ghi đè). Xem
:data:`MAX_SIMPLE_UPLOAD_BYTES`."""
_check_upload_size(data)
resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token,
data=data, headers={"Content-Type": "application/octet-stream"})
return resp.json()
def _encode_share_url(url: str) -> str: def _encode_share_url(url: str) -> str:
"""Encode a OneDrive/SharePoint sharing URL into Graph's ``u!<base64url>`` """Encode a OneDrive/SharePoint sharing URL into Graph's ``u!<base64url>``
share-id form (see Microsoft's 'Get access to shared items' docs).""" share-id form (see Microsoft's 'Get access to shared items' docs)."""
@@ -229,6 +267,24 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
return resp.json().get("value", []) return resp.json().get("value", [])
def download_sharepoint_file_bytes(token: str, site_id: str, path: str) -> bytes:
"""Đọc RAW BYTES một tệp trong thư viện tài liệu SharePoint — xem
:func:`download_onedrive_file_bytes`."""
resp = _request(
"GET", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token)
return resp.content
def upload_sharepoint_file_bytes(token: str, site_id: str, path: str, data: bytes) -> dict:
"""Ghi RAW BYTES vào một tệp trong thư viện tài liệu SharePoint. Xem
:data:`MAX_SIMPLE_UPLOAD_BYTES`."""
_check_upload_size(data)
resp = _request(
"PUT", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token,
data=data, headers={"Content-Type": "application/octet-stream"})
return resp.json()
# ---- Teams meeting transcripts ------------------------------------------ # ---- Teams meeting transcripts ------------------------------------------
def find_online_meeting(token: str, join_url: str) -> List[dict]: def find_online_meeting(token: str, join_url: str) -> List[dict]:
"""Tìm cuộc họp online theo link tham gia.""" """Tìm cuộc họp online theo link tham gia."""
+55
View File
@@ -24,6 +24,7 @@ project — nothing about it is special-cased in the UI.
from __future__ import annotations from __future__ import annotations
import json import json
import os
import re import re
from dataclasses import asdict, dataclass, field from dataclasses import asdict, dataclass, field
from datetime import datetime from datetime import datetime
@@ -65,6 +66,13 @@ class Project:
# auto_run: None → follow the global agent_security.cowork_confirm_commands; # auto_run: None → follow the global agent_security.cowork_confirm_commands;
# True → auto-approve commands (no confirm); False → always confirm. # True → auto-approve commands (no confirm); False → always confirm.
auto_run: Optional[bool] = None auto_run: Optional[bool] = None
# {} = an ordinary local/managed workspace. Non-empty when ``output_dir``
# is a LOCAL MIRROR of a OneDrive/SharePoint folder (see
# core/cloud_workspace_sync.py) — {"provider": "onedrive"|"sharepoint",
# "site_id": "", "site_name": "", "remote_path": ""}. ``output_dir`` itself
# always stays a real local path; nothing that reads ``workspace_dir()``
# needs to change because of this field.
cloud_source: Dict[str, str] = field(default_factory=dict)
def workspace_dir(self, base: Path = None) -> Path: def workspace_dir(self, base: Path = None) -> Path:
"""The project's sandbox root. Every chat of the project writes inside """The project's sandbox root. Every chat of the project writes inside
@@ -75,6 +83,53 @@ class Project:
return (base or WORKSPACES_DIR) / self.project_id return (base or WORKSPACES_DIR) / self.project_id
def _norm_dir(path) -> str:
"""Đường dẫn đã chuẩn hoá để đem ra so sánh.
Bung ``~``, đưa về tuyệt đối, rồi ``normcase`` — trên Windows thì
``D:/Work`` và ``d:/work`` là cùng một thư mục, nên so chuỗi thô sẽ
cho hai project chiếm chung một chỗ mà không ai biết.
"""
return os.path.normcase(os.path.abspath(os.path.expanduser(str(path))))
def _cham_nhau(a: str, b: str) -> bool:
"""Hai thư mục đã chuẩn hoá có chạm nhau không: trùng, hoặc lồng nhau.
Lồng nhau cũng tính, vì lý do tồn tại của sandbox là "agent của project này
không bao giờ chạm được file của project kia" (xem docstring đầu module).
Đứng ở thư mục cha thì đọc/ghi được toàn bộ thư mục con, nên cha-con vẫn là
chạm nhau dù hai đường dẫn không giống nhau.
"""
return a == b or a.startswith(b + os.sep) or b.startswith(a + os.sep)
def folder_conflict(path, *, ignore_id: str = "",
directory: Path = None) -> Optional[Project]:
"""Project khác đang chiếm ``path``, hoặc ``None`` nếu chưa ai chiếm.
Mỗi thư mục chỉ được thuộc về một project: thư mục làm việc vừa là sandbox
vừa là kho kiến thức dùng chung của project, nên hai project dùng chung một
thư mục là đọc lẫn dữ liệu của nhau.
So theo thư mục THỰC SỰ đang dùng (``workspace_dir()``), không phải theo
``output_dir``: project chưa đặt thư mục riêng vẫn đang chiếm thư mục quản
lý sẵn của nó, và chính thư mục đó là thứ hay bị chọn nhầm.
``ignore_id`` là project đang sửa — giữ nguyên thư mục của chính nó thì
không phải là trùng.
"""
if not str(path).strip():
return None
muon = _norm_dir(path)
for project in list_projects(directory):
if project.project_id == ignore_id:
continue
if _cham_nhau(muon, _norm_dir(project.workspace_dir())):
return project
return None
def _starter_project() -> Project: def _starter_project() -> Project:
"""An ordinary (deletable, renamable) project seeded when the projects """An ordinary (deletable, renamable) project seeded when the projects
folder is empty, so the app always opens with somewhere to chat.""" folder is empty, so the app always opens with somewhere to chat."""
+47 -3
View File
@@ -218,8 +218,13 @@ class SandboxManager:
timeout_sec: int, timeout_sec: int,
cancel: Optional[Callable[[], bool]] = None, cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]: ) -> Dict[str, Any]:
"""Dispatch execution to the selected backend.""" """Dispatch execution to the selected backend.
if backend == "direct":
With the network blocked every backend is replaced by the same OS-level
isolation: the backends below only ever set proxy env vars, which
anything that ignores proxies (raw sockets, ping, .NET WebClient...)
walked straight past."""
if block_network or backend == "direct":
return self._run_direct(command, workdir, block_network, timeout_sec, cancel) return self._run_direct(command, workdir, block_network, timeout_sec, cancel)
if backend == "integrity_job_wfp": if backend == "integrity_job_wfp":
@@ -274,7 +279,8 @@ class SandboxManager:
env = os.environ.copy() env = os.environ.copy()
if block_network: if block_network:
from .deps import network_blocked_env from .deps import network_blocked_env
env = network_blocked_env(env) env = network_blocked_env(env) # belt and braces on top of the OS block
return self._run_network_isolated(command, workdir, env, timeout_sec, cancel)
if cancel is not None: if cancel is not None:
from .deps import run_cancellable from .deps import run_cancellable
@@ -335,3 +341,41 @@ class SandboxManager:
"returncode": -1, "returncode": -1,
"sandbox": "direct", "sandbox": "direct",
} }
@staticmethod
def _run_network_isolated(
command: str,
workdir: str,
env: Dict[str, str],
timeout_sec: int,
cancel: Optional[Callable[[], bool]] = None,
) -> Dict[str, Any]:
"""Run ``command`` in a process the OS keeps off the network.
Fail-closed: when the isolation cannot be set up the command is
refused (``sandbox == "blocked"``), never run with the network open."""
from .deps import run_cancellable
rc, output, cancelled, timed_out, exceeded = run_cancellable(
command, cwd=workdir or None, timeout=timeout_sec, cancel=cancel,
shell=True, env=env, isolate_network=True,
)
if rc is None and not (cancelled or timed_out or exceeded):
return {"ok": False, "stdout": "", "returncode": -1, "sandbox": "blocked",
"stderr": ("Command refused: network is blocked and the command could "
f"not be isolated from the network ({output.strip()}).")}
if cancelled:
stderr = "Cancelled by user."
elif timed_out:
stderr = f"Timeout after {timeout_sec}s"
elif exceeded:
stderr = "Resource limit exceeded."
else:
stderr = ""
return {
"ok": rc == 0 and not (cancelled or timed_out or exceeded),
"stdout": output,
"stderr": stderr,
"returncode": rc if rc is not None else -1,
"sandbox": "network_isolated",
}
+1 -13
View File
@@ -19,7 +19,6 @@ in Waiting Input), so executors here run with an auto gate.
""" """
from __future__ import annotations from __future__ import annotations
import subprocess
import time import time
import uuid import uuid
from datetime import datetime from datetime import datetime
@@ -30,6 +29,7 @@ from . import agent_roles
from . import agent_security from . import agent_security
from . import projects from . import projects
from .permissions import PermissionGate from .permissions import PermissionGate
from .task_script import run_script as _run_script
from .tasks import ARTIFACTS_DIR, resolve_input_text from .tasks import ARTIFACTS_DIR, resolve_input_text
from .tools import ToolContext from .tools import ToolContext
@@ -358,18 +358,6 @@ def _run_agent(ctx, task_type: str, prompt: str, out_dir: Path,
return _last_assistant_text(messages), timed_out(), incomplete return _last_assistant_text(messages), timed_out(), incomplete
def _run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
if not command.strip():
raise RuntimeError("Script task has no command configured.")
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
capture_output=True, text=True, timeout=max(1, timeout_sec))
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
if proc.returncode != 0:
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
return output
def execute_task(ctx, task: Dict[str, Any], run_id: str, def execute_task(ctx, task: Dict[str, Any], run_id: str,
emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None, emit: Optional[EmitFn] = None, cancel: Optional[CancelFn] = None,
tasks_dir: Path = None) -> Dict[str, Any]: tasks_dir: Path = None) -> Dict[str, Any]:
+47
View File
@@ -0,0 +1,47 @@
"""Run a scheduled task of type ``script`` (tách khỏi ``task_executors.py``).
Khi công tắc "Chặn mạng" đang bật, lệnh của task chạy trong tiến trình bị hệ
điều hành cắt mạng — giống ``run_command`` của agent. Trước đây task script
chạy thẳng bằng ``subprocess.run``, không sandbox, nên lên mạng tự do.
"""
from __future__ import annotations
import subprocess
from pathlib import Path
def run_script(command: str, out_dir: Path, timeout_sec: int) -> str:
"""Chạy một task kiểu script bằng shell trong thư mục kết quả, có hạn giờ."""
if not command.strip():
raise RuntimeError("Script task has no command configured.")
from ..application.network import network_guard
if network_guard.is_blocked():
return _run_script_without_network(command, out_dir, timeout_sec)
proc = subprocess.run(command, shell=True, cwd=str(out_dir),
capture_output=True, text=True, timeout=max(1, timeout_sec))
output = (proc.stdout or "") + (("\n[stderr]\n" + proc.stderr) if proc.stderr else "")
if proc.returncode != 0:
raise RuntimeError(f"Script exited with code {proc.returncode}:\n{output[-2000:]}")
return output
def _run_script_without_network(command: str, out_dir: Path, timeout_sec: int) -> str:
"""Như :func:`run_script`, nhưng tiến trình không có mạng; không cô lập được thì không chạy."""
from .deps import network_blocked_env, run_cancellable
rc, output, _cancelled, timed_out, _exceeded = run_cancellable(
command, cwd=str(out_dir), timeout=max(1, timeout_sec), shell=True,
env=network_blocked_env(), isolate_network=True,
)
if timed_out:
raise subprocess.TimeoutExpired(command, timeout_sec)
if rc is None:
raise RuntimeError("Script not run: network is blocked and the script could not be "
f"isolated from the network ({output.strip()}).")
if rc != 0:
raise RuntimeError(f"Script exited with code {rc} (network blocked):\n{output[-2000:]}")
return output
__all__ = ["run_script"]
+13 -8
View File
@@ -258,9 +258,13 @@ def dependencies_met(task: Dict[str, Any], directory: Path = None) -> bool:
def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str, def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
depends_on: List[str]) -> Optional[str]: depends_on: List[str]) -> Optional[str]:
"""Validate a proposed depends_on list: no self-wait, no wait-cycle """Validate a proposed depends_on list: no self-wait, no wait-cycle
(A waits B while B — directly or transitively — waits A).""" (A waits B while B — directly or transitively — waits A).
Trả về KHOÁ i18n chứ không phải câu đã dịch: tầng này không biết người dùng
đang chọn ngôn ngữ nào, nên nơi hiển thị mới là nơi gọi ``tr()``.
"""
if task_id in (depends_on or []): if task_id in (depends_on or []):
return "A task cannot wait for itself." return "schedtask.err_self_wait"
by_id = {t["task_id"]: t for t in tasks} by_id = {t["task_id"]: t for t in tasks}
# DFS from each proposed prerequisite through ITS prerequisites. # DFS from each proposed prerequisite through ITS prerequisites.
for start in depends_on or []: for start in depends_on or []:
@@ -268,7 +272,7 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
while stack: while stack:
cur = stack.pop() cur = stack.pop()
if cur == task_id: if cur == task_id:
return "This would create a circular wait between tasks." return "schedtask.err_wait_cycle"
if cur in seen: if cur in seen:
continue continue
seen.add(cur) seen.add(cur)
@@ -280,22 +284,23 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
def chain_error(tasks: List[Dict[str, Any]], task_id: str, def chain_error(tasks: List[Dict[str, Any]], task_id: str,
next_task_id: Optional[str]) -> Optional[str]: next_task_id: Optional[str]) -> Optional[str]:
"""Validate assigning ``next_task_id`` as ``task_id``'s next task. """Validate assigning ``next_task_id`` as ``task_id``'s next task.
Returns an error string (self-link / circular chain / unknown id), or Returns an i18n KEY for the problem (self-link / circular chain / unknown
None when the assignment is safe.""" id), or None when the assignment is safe. Khoá chứ không phải câu đã dịch —
xem ``depends_cycle_error``."""
if not next_task_id: if not next_task_id:
return None return None
if next_task_id == task_id: if next_task_id == task_id:
return "A task cannot chain to itself." return "schedtask.err_self_chain"
by_id = {t["task_id"]: t for t in tasks} by_id = {t["task_id"]: t for t in tasks}
if next_task_id not in by_id: if next_task_id not in by_id:
return "Next task does not exist." return "schedtask.err_next_missing"
# Walk forward from the proposed next task; reaching task_id again means # Walk forward from the proposed next task; reaching task_id again means
# the new edge would close a cycle. # the new edge would close a cycle.
seen = {task_id} seen = {task_id}
cur = next_task_id cur = next_task_id
while cur: while cur:
if cur in seen: if cur in seen:
return "This would create a circular task chain." return "schedtask.err_chain_cycle"
seen.add(cur) seen.add(cur)
cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id") cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id")
return None return None
+4
View File
@@ -43,6 +43,10 @@ class TeamsNotifier:
"""Post a notification. Returns ``(ok, detail)``.""" """Post a notification. Returns ``(ok, detail)``."""
if not self.configured: if not self.configured:
return False, "Teams webhook URL is not configured." return False, "Teams webhook URL is not configured."
from ..application.network import network_guard
if network_guard.is_blocked():
return False, network_guard.refusal("Teams notification")
# Workflows webhooks expect an Adaptive Card; classic connectors expect a # Workflows webhooks expect an Adaptive Card; classic connectors expect a
# MessageCard. Try both, then a plain-text fallback. # MessageCard. Try both, then a plain-text fallback.
+23
View File
@@ -0,0 +1,23 @@
Source HEAD: db80289 (preserved)
Branch: perf/fsg-performance
Baseline: app import 1517ms; config 934ms; MainWindow 1742ms (offscreen, local machine).
Packets completed:
- P0: measured constructor with cProfile; dominant cost was provider model discovery (~0.7s network worker) and eager Workspace composition.
- P2: cache history listing by directory mtime/query and coalesce sidebar refresh bursts.
- P3: batch streaming Markdown/layout renders at 40ms; final content remains intact.
- P4: bounded attachment discovery avoids sorting a full recursive tree when a cap is set.
- P5: instrument monitoring log refresh; existing 30-day bounded window retained.
- P6: defer provider model discovery to the first Qt event-loop turn.
After: config 452ms; MainWindow 599ms in the same offscreen smoke benchmark (discovery no longer blocks construction).
Streaming render count is now bounded by batch cadence rather than token count.
Representative history benchmark: 1,000 files 383.6ms cold / 0.8ms cached on this machine.
Relevant commits: c5cb258 (perf: defer discovery and reduce UI refresh work).
Remaining bottleneck: eager Workspace/Co4E/Folder widget construction and import-time PySide6 overhead.
Closure pass (starting HEAD 58b5220): Workspace now keeps Co4E, Folder, and GraphRAG as tab placeholders and creates each once on first selection. MainWindow benchmark: 357.6ms; first opens Co4E 143.1ms, Folder 148.0ms, GraphRAG 270.6ms; repeat opens 0.0–2.4ms. Focused lazy navigation/project tests: 15 passed. Pytest temp failures were ACL/path setup issues, not production assertions; a pre-created writable repository-local temp base allowed the focused gates to pass.
Remaining startup cost is base PySide6/application import and eager Cowork shell; further lazy work is not justified without broader architectural risk.
Performance initiative status: closed for this pass.
+6
View File
@@ -48,6 +48,9 @@ from . import skills_dialog as _skills_dialog
from . import libreoffice_view as _libreoffice_view from . import libreoffice_view as _libreoffice_view
from . import agents_admin_tab as _agents_admin_tab from . import agents_admin_tab as _agents_admin_tab
from . import monitoring_overview as _monitoring_overview from . import monitoring_overview as _monitoring_overview
from . import cloud_workspace as _cloud_workspace
from . import dialog_buttons as _dialog_buttons
from . import connectors as _connectors
# Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả # Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả
# còn nằm chung một dict literal. # còn nằm chung một dict literal.
@@ -62,6 +65,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
**_libreoffice_view.STRINGS, **_libreoffice_view.STRINGS,
**_agents_admin_tab.STRINGS, **_agents_admin_tab.STRINGS,
**_monitoring_overview.STRINGS, **_monitoring_overview.STRINGS,
**_cloud_workspace.STRINGS,
**_dialog_buttons.STRINGS,
**_connectors.STRINGS,
} }
+8
View File
@@ -232,6 +232,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "行をフィルター(質問を入力しても可)…", "ja": "行をフィルター(質問を入力しても可)…",
"vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"}, "vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"},
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"}, "monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"monitoring.page_size_label": {
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
"monitoring.page_indicator": {
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
"monitoring.page_prev": {
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
"monitoring.page_next": {
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
"monitoring.pricing_title": { "monitoring.pricing_title": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)", "en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"vi": "Bảng giá model (USD / 1 triệu token)"}, "vi": "Bảng giá model (USD / 1 triệu token)"},
+116
View File
@@ -0,0 +1,116 @@
"""DF-007 — Microsoft 365 sign-in dialog + cloud (OneDrive/SharePoint)
folder picker. Deliberately its own module rather than reusing the
similarly-named orphaned keys under ``settings.ms365_*`` in ``cowork_tab.py``/
``settings_dialog.py`` — those are leftovers from a MS365 sign-in UI that was
removed (see ``ui/settings_dialog.py`` module docstring) and the two files
disagree with each other on wording for several duplicate keys, so reusing
them risked resurrecting an inconsistency rather than a clean, tested string
set."""
from __future__ import annotations
STRINGS = {
# ---- ui/ms365_signin_dialog.py ----
"ms365_signin.title": {
"en": "Sign in to Microsoft 365", "ja": "Microsoft 365 にサインイン",
"vi": "Đăng nhập Microsoft 365",
},
"ms365_signin.already": {
"en": "Signed in as {who}.", "ja": "{who} としてサインイン済みです。",
"vi": "Đã đăng nhập với {who}.",
},
"ms365_signin.intro": {
"en": "Sign in with your Microsoft work/school (or personal) account to "
"browse OneDrive/SharePoint folders.",
"ja": "OneDrive/SharePoint のフォルダーを参照するには、Microsoft の職場/学校\n"
"(または個人) アカウントでサインインしてください。",
"vi": "Đăng nhập bằng tài khoản Microsoft (công ty/trường học hoặc cá nhân) "
"để duyệt thư mục OneDrive/SharePoint.",
},
"ms365_signin.button": {
"en": "Sign in", "ja": "サインイン", "vi": "Đăng nhập",
},
"ms365_signin.signing_in": {
"en": "Signing in…", "ja": "サインイン中…", "vi": "Đang đăng nhập…",
},
"ms365_signin.code_hint": {
"en": "Open {url} and enter this code:", "ja": "{url} を開いてこのコードを入力してください:",
"vi": "Mở {url} và nhập mã sau:",
},
"ms365_signin.open_link": {
"en": "Open link", "ja": "リンクを開く", "vi": "Mở link",
},
"ms365_signin.failed": {
"en": "Sign-in failed: {err}", "ja": "サインインに失敗しました: {err}",
"vi": "Đăng nhập thất bại: {err}",
},
"ms365_signin.cancel": {
"en": "Cancel", "ja": "キャンセル", "vi": "Hủy",
},
# ---- ui/cloud_folder_picker_dialog.py ----
"cloud_picker.title": {
"en": "Choose a OneDrive/SharePoint folder", "ja": "OneDrive/SharePoint フォルダーを選択",
"vi": "Chọn thư mục OneDrive/SharePoint",
},
"cloud_picker.source_onedrive": {
"en": "My OneDrive", "ja": "自分の OneDrive", "vi": "OneDrive của tôi",
},
"cloud_picker.source_sharepoint": {
"en": "SharePoint site", "ja": "SharePoint サイト", "vi": "Site SharePoint",
},
"cloud_picker.search_sites_placeholder": {
"en": "Search SharePoint sites…", "ja": "SharePoint サイトを検索…",
"vi": "Tìm site SharePoint…",
},
"cloud_picker.search_btn": {
"en": "Search", "ja": "検索", "vi": "Tìm",
},
"cloud_picker.up": {
"en": ".. (up)", "ja": ".. (上へ)", "vi": ".. (lùi lại)",
},
"cloud_picker.choose_here": {
"en": "Choose this folder", "ja": "このフォルダーを選択", "vi": "Chọn thư mục này",
},
"cloud_picker.cancel": {
"en": "Cancel", "ja": "キャンセル", "vi": "Hủy",
},
"cloud_picker.load_failed": {
"en": "Could not load this folder: {err}", "ja": "フォルダーを読み込めませんでした: {err}",
"vi": "Không tải được thư mục này: {err}",
},
"cloud_picker.no_sites": {
"en": "No matching SharePoint sites.", "ja": "一致する SharePoint サイトがありません。",
"vi": "Không tìm thấy site SharePoint phù hợp.",
},
# ---- ui/workspace_tab.py additions ----
"workspace.cloud_pick": {
"en": "Choose from OneDrive/SharePoint…", "ja": "OneDrive/SharePoint から選択…",
"vi": "Chọn từ OneDrive/SharePoint…",
},
"workspace.cloud_sync": {
"en": "Sync with cloud", "ja": "クラウドと同期", "vi": "Đồng bộ với cloud",
},
"workspace.cloud_badge_onedrive": {
"en": "☁ Local mirror of OneDrive: {path}", "ja": "☁ OneDrive のローカルミラー: {path}",
"vi": "☁ Bản sao cục bộ của OneDrive: {path}",
},
"workspace.cloud_badge_sharepoint": {
"en": "☁ Local mirror of SharePoint ({site}): {path}",
"ja": "☁ SharePoint ({site}) のローカルミラー: {path}",
"vi": "☁ Bản sao cục bộ của SharePoint ({site}): {path}",
},
"workspace.cloud_sync_result": {
"en": "Sync done — {up} uploaded, {down} downloaded.",
"ja": "同期完了 — アップロード {up} 件、ダウンロード {down} 件。",
"vi": "Đồng bộ xong — {up} tệp đẩy lên, {down} tệp tải về.",
},
"workspace.cloud_sync_errors": {
"en": "{n} item(s) had errors — see details below.",
"ja": "{n} 件のエラーがありました — 詳細は下記のとおりです。",
"vi": "{n} mục bị lỗi — chi tiết bên dưới.",
},
"workspace.cloud_sync_skipped": {
"en": "{n} file(s) skipped (over 4 MB, not supported yet).",
"ja": "{n} 件のファイルはスキップされました (4 MB 超、未対応)。",
"vi": "{n} tệp bị bỏ qua (quá 4 MB, chưa hỗ trợ).",
},
}
+32
View File
@@ -0,0 +1,32 @@
"""Chuỗi hiển thị — nhóm Connector (Giám sát ▸ Công cụ ▸ Connector).
Tên bốn nhóm catalog trên bảng Connector. Đứng riêng một file vì
``libreoffice_view.py`` — nơi giữ các khoá ``connectors.*`` cũ — đã sát trần
400 dòng của Gate S; khoá connector thêm mới đi vào đây.
Ba nhóm CAD / CAE / MS365 là DANH SÁCH TÊN SẢN PHẨM nên giống hệt nhau ở cả ba
ngôn ngữ (đã khai vào ``KHOA_KHONG_CAN_DICH`` của test i18n). Chỉ nhóm "Other"
có chữ thật để dịch — đúng chỗ người dùng báo còn nguyên tiếng Anh.
``ui/connectors_panel.py`` tách nhãn tại chuỗi ``" ("`` để in phần trong ngoặc
bằng kiểu chữ phụ, nên bản dịch phải dùng ngoặc ĐƠN NỬA CHIỀU RỘNG kèm một dấu
cách phía trước — dùng ngoặc full-width ``(`` của tiếng Nhật thì không tách
được và cả cụm sẽ in đậm thành một khối.
"""
from __future__ import annotations
from typing import Dict
_CAD = "CAD (NX / CATIA / SolidWorks / AutoCAD)"
_CAE = "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)"
_MS365 = "MS365 (Microsoft 365 / OneDrive / SharePoint)"
STRINGS: Dict[str, Dict[str, str]] = {
"connectors.cat_cad": {"en": _CAD, "ja": _CAD, "vi": _CAD},
"connectors.cat_cae": {"en": _CAE, "ja": _CAE, "vi": _CAE},
"connectors.cat_ms365": {"en": _MS365, "ja": _MS365, "vi": _MS365},
"connectors.cat_other": {
"en": "Other (any generic MCP server)",
"ja": "その他 (任意の汎用 MCP サーバー)",
"vi": "Khác (MCP server bất kỳ)"},
}
-6
View File
@@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Let the control agent review a command with AI before it runs.", "en": "Let the control agent review a command with AI before it runs.",
"ja": "実行前に制御エージェントがAIでコマンドを確認します。", "ja": "実行前に制御エージェントがAIでコマンドを確認します。",
"vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."}, "vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."},
"settings.sandbox_pw_unset_title": {
"en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"},
"settings.sandbox_pw_unset_body": {
"en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.",
"ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。",
"vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."},
"settings.sandbox_confirm_commands": { "settings.sandbox_confirm_commands": {
"en": "Confirm before Cowork runs a command", "en": "Confirm before Cowork runs a command",
"ja": "Cowork がコマンドを実行する前に確認する", "ja": "Cowork がコマンドを実行する前に確認する",
+23
View File
@@ -0,0 +1,23 @@
"""Nhãn cho các nút CHUẨN của Qt (Save/Cancel/OK/Close, Yes/No).
Qt tự vẽ chữ cho những nút này từ bảng dịch của chính nó, mà ứng dụng không
cài ``QTranslator`` nào — nên chúng đứng nguyên tiếng Anh ở cả ba ngôn ngữ.
``ui/dialog_buttons.py`` gán lại nhãn bằng các khoá dưới đây.
Khoá dùng chung cho mọi hộp thoại nên đứng riêng một file, không nhét vào file
của một màn hình cụ thể.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"dialog.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"dialog.cancel": {"en": "Cancel", "ja": "キャンセル", "vi": "Hủy"},
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — kể cả bản tiếng Nhật của Qt cũng
# dùng "OK". Đã khai vào KHOA_KHONG_CAN_DICH của test i18n.
"dialog.ok": {"en": "OK", "ja": "OK", "vi": "OK"},
"dialog.close": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"dialog.yes": {"en": "Yes", "ja": "はい", "vi": "Có"},
"dialog.no": {"en": "No", "ja": "いいえ", "vi": "Không"},
}
+19
View File
@@ -339,4 +339,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "No usage recorded in this period yet — run a chat or a task first.", "en": "No usage recorded in this period yet — run a chat or a task first.",
"ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。", "ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。",
"vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."}, "vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."},
# Lỗi hợp lệ hoá phụ thuộc/chuỗi task: ``core/tasks.py`` trả về KHOÁ, nơi
# hiển thị mới gọi ``tr()`` (tầng core không biết ngôn ngữ đang chọn).
"schedtask.err_self_wait": {
"en": "A task cannot wait for itself.", "ja": "タスクは自分自身を待てません。",
"vi": "Một task không thể chờ chính nó."},
"schedtask.err_wait_cycle": {
"en": "This would create a circular wait between tasks.",
"ja": "タスク間で待ち合わせが循環してしまいます。",
"vi": "Việc này sẽ tạo vòng chờ luẩn quẩn giữa các task."},
"schedtask.err_self_chain": {
"en": "A task cannot chain to itself.", "ja": "タスクは自分自身に連結できません。",
"vi": "Một task không thể nối tiếp chính nó."},
"schedtask.err_next_missing": {
"en": "Next task does not exist.", "ja": "次のタスクが存在しません。",
"vi": "Task kế tiếp không tồn tại."},
"schedtask.err_chain_cycle": {
"en": "This would create a circular task chain.",
"ja": "タスクの連結が循環してしまいます。",
"vi": "Việc này sẽ tạo chuỗi task luẩn quẩn."},
} }
+45
View File
@@ -134,6 +134,45 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. " "vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. "
"Connector MCP / REST-API được thiết lập ở tab con Connector."}, "Connector MCP / REST-API được thiết lập ở tab con Connector."},
"tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"}, "tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
# Mô tả tool HIỂN THỊ trên thẻ, một khoá cho mỗi ``TOOL_SPECS[].name``.
# KHÔNG dùng ``spec.description``: chuỗi đó là mô tả gửi cho mô hình trong
# schema function-calling, phải giữ nguyên tiếng Anh và viết cho máy đọc.
"tools_admin.desc.read_file": {
"en": "Read the contents of a text file in the working folder.",
"ja": "作業フォルダー内のテキストファイルの内容を読み取ります。",
"vi": "Đọc nội dung một tệp văn bản trong thư mục làm việc."},
"tools_admin.desc.list_dir": {
"en": "List files and subfolders at a path (defaults to the workdir root).",
"ja": "指定パスのファイルとサブフォルダーを一覧表示します(既定は作業フォルダー直下)。",
"vi": "Liệt kê tệp và thư mục con tại một đường dẫn (mặc định là gốc thư mục làm việc)."},
"tools_admin.desc.write_file": {
"en": "Create a new file or fully rewrite one. For small edits, prefer edit_file.",
"ja": "ファイルを新規作成、または全体を書き換えます。小さな修正には edit_file を使います。",
"vi": "Tạo tệp mới hoặc ghi đè toàn bộ. Sửa nhỏ thì nên dùng edit_file."},
"tools_admin.desc.edit_file": {
"en": "Replace an exact snippet inside an existing file — preferred for small edits.",
"ja": "既存ファイル内の特定の箇所を置き換えます。小さな修正に適しています。",
"vi": "Thay chính xác một đoạn trong tệp có sẵn — hợp cho các sửa đổi nhỏ."},
"tools_admin.desc.run_command": {
"en": "Run a shell command in the working folder and return its output.",
"ja": "作業フォルダーでシェルコマンドを実行し、その出力を返します。",
"vi": "Chạy một lệnh shell trong thư mục làm việc và trả về kết quả."},
"tools_admin.desc.install_package": {
"en": "Install a Python package (pip) so the task can use a missing library.",
"ja": "不足しているライブラリを使えるよう Python パッケージ(pip)をインストールします。",
"vi": "Cài gói Python (pip) để tác vụ dùng được thư viện còn thiếu."},
"tools_admin.desc.fetch_url": {
"en": "Fetch a web page or online document by URL and return its text.",
"ja": "URL から Web ページやオンライン文書を取得し、テキストを返します。",
"vi": "Tải trang web hoặc tài liệu trực tuyến theo URL và trả về nội dung văn bản."},
"tools_admin.desc.jira_search": {
"en": "Search Jira issues with a JQL query and return a summary list. Read-only.",
"ja": "JQL クエリで Jira の課題を検索し、一覧を返します。読み取り専用です。",
"vi": "Tìm issue Jira bằng truy vấn JQL và trả về danh sách tóm tắt. Chỉ đọc."},
"tools_admin.desc.jira_get_issue": {
"en": "Read one Jira issue's details by key, e.g. ABX-123.",
"ja": "キー(例: ABX-123)を指定して Jira 課題の詳細を読み取ります。",
"vi": "Đọc chi tiết một issue Jira theo mã, ví dụ ABX-123."},
"tools_admin.url_fetch_group": { "tools_admin.url_fetch_group": {
"en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)", "en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)",
"vi": "Truy cập web (fetch_url)"}, "vi": "Truy cập web (fetch_url)"},
@@ -268,6 +307,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"}, "co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"},
"co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"}, "co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"},
"co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"}, "co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"},
"co4e.canvas_add_next": {
"en": "Add next step", "ja": "次のステップを追加", "vi": "Thêm bước kế"},
"co4e.canvas_connect_from": {
"en": "Connect from here", "ja": "ここから接続", "vi": "Nối từ đây"},
"co4e.canvas_delete_edge": {
"en": "Delete connection", "ja": "接続を削除", "vi": "Xóa liên kết"},
"co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"}, "co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"},
"co4e.fit_tooltip": { "co4e.fit_tooltip": {
"en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示", "en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示",
+3
View File
@@ -177,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Project cho đoạn chat mới"}, "vi": "Project cho đoạn chat mới"},
"app.nav.no_project": { "app.nav.no_project": {
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"}, "en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
# KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào.
"app.nav.pick_project": {
"en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"},
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"}, "app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
"app.nav.all_projects": { "app.nav.all_projects": {
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"}, "en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
+17 -11
View File
@@ -19,13 +19,13 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"}, "vi": "Cho phép agent lấy dữ liệu từ URL (trang web, link SharePoint / OneDrive)"},
"settings.allow_url_fetch_tooltip": { "settings.allow_url_fetch_tooltip": {
"en": ("Lets the agent's fetch_url tool read web pages, online documents and " "en": ("Lets the agent's fetch_url tool read web pages, online documents and "
"SharePoint/OneDrive share links to search & process them. Separate from " "SharePoint/OneDrive share links to search & process them. 'Block network' "
"'Block network' (which only sandboxes shell commands). Default: on."), "overrides this switch. Default: on."),
"ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを" "ja": "エージェントのfetch_urlツールがWebページ・オンライン文書・SharePoint/OneDrive共有リンクを"
"読み取れるようにします。「ネットワークをブロック」(シェルコマンド用)とは別です。既定: オン。", "読み取れるようにします。「ネットワークをブロック」がオンの場合はそちらが優先されます。既定: オン。",
"vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ " "vi": ("Cho phép tool fetch_url của agent đọc trang web, tài liệu online và link chia sẻ "
"SharePoint/OneDrive để tìm kiếm & xử lý. Tách biệt với 'Chặn mạng' (chỉ áp cho lệnh " "SharePoint/OneDrive để tìm kiếm & xử lý. 'Chặn mạng' được ưu tiên hơn công tắc "
"shell). Mặc định: bật.")}, "này. Mặc định: bật.")},
"settings.test_internet": { "settings.test_internet": {
"en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"}, "en": "Test Internet", "ja": "インターネット接続テスト", "vi": "Kiểm tra Internet"},
"settings.test_internet_tooltip": { "settings.test_internet_tooltip": {
@@ -39,12 +39,18 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Testing internet access…", "ja": "インターネット接続をテスト中…", "en": "Testing internet access…", "ja": "インターネット接続をテスト中…",
"vi": "Đang kiểm tra truy cập internet…"}, "vi": "Đang kiểm tra truy cập internet…"},
"settings.sandbox_block_network_tooltip": { "settings.sandbox_block_network_tooltip": {
"en": ("Policy-level control (proxy env vars point at a black hole) — not a kernel " "en": ("Only the AI provider (chat, model list, model test) may reach the network. "
"firewall. Combine with the command whitelist above for defense in depth."), "Agent shell commands and task scripts run in a Windows AppContainer with no "
"ja": "ポリシーレベルの制御です(プロキシ環境変数をブラックホールに向ける)— カーネルレベルの" "network access; fetch_url, Jira, connectors/MCP, Microsoft 365, Teams, "
"ファイアウォールではありません。上のコマンドホワイトリストと併用してください。", "connector tests, pip auto-install and remote content in HTML previews are refused."),
"vi": "Kiểm soát ở tầng chính sách (trỏ biến môi trường proxy vào hố đen) — không phải " "ja": "ネットワークに接続できるのはAIプロバイダー(チャット・モデル一覧・モデルテスト)のみです。"
"firewall tầng kernel. Kết hợp với whitelist lệnh ở trên để phòng thủ nhiều lớp."}, "エージェントのシェルコマンドとタスクスクリプトはネットワークなしのWindows AppContainerで実行され、"
"fetch_url・Jira・コネクタ/MCP・Microsoft 365・Teams・接続テスト・pip自動インストール・"
"HTMLプレビューの外部リソースは拒否されます。",
"vi": "Chỉ nhà cung cấp AI (chat, tải danh sách model, thử model) được ra mạng. Lệnh shell "
"của agent và task script chạy trong Windows AppContainer không có mạng; fetch_url, "
"Jira, connector/MCP, Microsoft 365, Teams, nút Test, tự cài thư viện và tài nguyên "
"web trong xem trước HTML đều bị từ chối."},
"settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"}, "settings.sandbox_unlimited": {"en": "Unlimited", "ja": "無制限", "vi": "Không giới hạn"},
"settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"}, "settings.sandbox_cpu_label": {"en": "CPU limit", "ja": "CPU 制限", "vi": "Giới hạn CPU"},
"settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"}, "settings.sandbox_memory_label": {"en": "Memory limit", "ja": "メモリ制限", "vi": "Giới hạn bộ nhớ"},
+24 -1
View File
@@ -57,6 +57,17 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.", "en": "Another project is already called \"{name}\". Project names must be unique — the list shows nothing but the name, so two of them cannot be told apart.",
"ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。", "ja": "「{name}」という名前のプロジェクトが既にあります。一覧には名前しか出ないため、同じ名前が二つあると区別できません。",
"vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."}, "vi": "Đã có project khác tên \"{name}\". Tên project phải khác nhau — danh sách chỉ hiện tên, trùng tên là không phân biệt được."},
"workspace.folder_taken_title": {
"en": "Folder already used", "ja": "フォルダーが重複しています",
"vi": "Thư mục đã được dùng"},
"workspace.folder_taken_body": {
"en": "Project \"{name}\" already works in {folder}. One folder belongs to one project only — the folder is that project's sandbox and shared knowledge, so sharing it lets two projects read and overwrite each other's files. Pick another folder.",
"ja": "プロジェクト「{name}」が既に {folder} を使用しています。フォルダーは 1 つのプロジェクト専用です — フォルダーはそのプロジェクトのサンドボックス兼共有ナレッジなので、共有すると互いのファイルを読み書きしてしまいます。別のフォルダーを選んでください。",
"vi": "Project \"{name}\" đang làm việc trong {folder}. Mỗi thư mục chỉ thuộc về một project — thư mục vừa là sandbox vừa là kho kiến thức chung của project đó, dùng chung là hai project đọc và ghi đè file của nhau. Hãy chọn thư mục khác."},
"workspace.folder_shared_warning": {
"en": "⚠ This folder is also used by project \"{name}\". One folder belongs to one project only — pick another folder for one of them.",
"ja": "⚠ このフォルダーはプロジェクト「{name}」でも使われています。フォルダーは 1 つのプロジェクト専用です — どちらかに別のフォルダーを指定してください。",
"vi": "⚠ Thư mục này đang được project \"{name}\" dùng chung. Mỗi thư mục chỉ thuộc về một project — hãy đổi thư mục cho một trong hai."},
"workspace.instructions_placeholder": { "workspace.instructions_placeholder": {
"en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"", "en": "e.g. \"All answers in Vietnamese. We are building the X reporting tool; always follow the naming rules …\"",
"ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」", "ja": "例:「回答はすべて日本語で。X レポートツールを開発中。命名規則に従うこと …」",
@@ -79,7 +90,10 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.status.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."}, "app.status.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."},
"app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."}, "app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."},
"app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."}, "app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."},
"app.credit": {"en": "Made by QuanDH14", "ja": "Made by QuanDH14", "vi": "Made by QuanDH14"}, # Con số lấy từ ``cowork_local.__version__`` — một nguồn duy nhất cho tiêu
# đề cửa sổ, tab Giới thiệu và góc dưới phải. Giữ nguyên dạng ở cả ba ngôn
# ngữ (đã khai vào KHOA_KHONG_CAN_DICH).
"app.version": {"en": "Version {v}", "ja": "Version {v}", "vi": "Version {v}"},
"app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"}, "app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"},
"app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"}, "app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
"app.tray.running_body": { "app.tray.running_body": {
@@ -197,6 +211,15 @@ STRINGS: Dict[str, Dict[str, str]] = {
"chat.provider_default_short": { "chat.provider_default_short": {
"en": "the provider's default model", "ja": "プロバイダー既定のモデル", "en": "the provider's default model", "ja": "プロバイダー既定のモデル",
"vi": "model mặc định của provider"}, "vi": "model mặc định của provider"},
"chat.provider_default_item": {
"en": "(provider default)", "ja": "(プロバイダー既定)",
"vi": "(mặc định của provider)"},
"chat.record_audio_start": {
"en": "Record Voice Note", "ja": "ボイスメモを録音", "vi": "Ghi âm ghi chú"},
"chat.record_audio_stop": {
"en": "Stop Recording", "ja": "録音を停止", "vi": "Dừng ghi âm"},
"chat.record_audio_cancel": {
"en": "Cancel recording", "ja": "録音をキャンセル", "vi": "Hủy ghi âm"},
"chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"chat.delete_tooltip": { "chat.delete_tooltip": {
"en": "Delete this message and its input/output files", "en": "Delete this message and its input/output files",
+27 -1
View File
@@ -62,7 +62,9 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
""" """
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
from cowork_local.security.command_risk_classifier import classify_command from cowork_local.security.command_risk_classifier import (
classify_command, command_bypasses_network_proxy,
)
command = str(args.get("command", "")).strip() command = str(args.get("command", "")).strip()
if not command: if not command:
@@ -74,6 +76,21 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
denial = "Command blocked by security policy: " + "; ".join(risk.reasons) denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial} return {"ok": False, "output": denial}
# With the network blocked, SandboxManager runs the command in an OS-level
# network-less process (AppContainer on Windows — see
# infrastructure/sandbox/network_isolation.py). Tools that exist only to
# reach the network (ping, nslookup, ssh...) are still denied BY NAME
# first: the model gets a clear reason instead of a cryptic socket error.
if ctx.block_network:
bypass_tool = command_bypasses_network_proxy(command)
if bypass_tool:
return {"ok": False, "output": (
f"Command blocked: '{bypass_tool}' can reach the network without going through "
"an HTTP proxy, so the sandbox's network block (which only filters proxy-aware "
"traffic) cannot stop it by itself — blocked by name instead while "
"'Chặn mạng cho lệnh do agent chạy' is on."
)}
# Route through SandboxManager for risk-based isolation # Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig( mgr = SandboxManager(ExecutionConfig(
enabled=True, enabled=True,
@@ -111,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any],
package = str(args.get("package", "")).strip() package = str(args.get("package", "")).strip()
if not package: if not package:
return {"ok": False, "output": "No package specified."} return {"ok": False, "output": "No package specified."}
# ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy
# subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của
# ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách
# run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip
# thử 600 giây rồi báo một lỗi proxy khó hiểu.
if ctx.block_network:
return {"ok": False, "output": (
"install_package: network access is blocked by the Sandbox Security Layer "
"(\"Block network for agent-run commands\" is on in Settings).")}
python = _sandbox_python(ctx, cancel, on_output) python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python) ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}." head = f"Installed {package}." if ok else f"Could not install {package}."
+25 -1
View File
@@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict from typing import Any, Dict, Optional
from .tool_context import ToolContext from .tool_context import ToolContext
def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]:
"""Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi.
``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell),
nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình
thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời
gọi mạng, để công tắc chặn đúng thứ nó nói là chặn.
"""
if not ctx.block_network:
return None
return {"ok": False, "output": (
f"{tool}: network access is blocked by the Sandbox Security Layer "
"(\"Block network for agent-run commands\" is on in Settings).")}
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint- """Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments OneDrive share link) via link_fetch — the same parser task-link attachments
@@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
return {"ok": False, "output": "fetch_url: 'url' is required."} return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")): if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"} return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
blocked = _network_refusal(ctx, "fetch_url")
if blocked is not None:
return blocked
if not ctx.allow_url_fetch: if not ctx.allow_url_fetch:
return {"ok": False, return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security " "output": ("fetch_url: URL fetching is turned off in Settings → Security "
@@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Tìm issue trên Jira bằng JQL.""" """Tìm issue trên Jira bằng JQL."""
blocked = _network_refusal(ctx, "jira_search")
if blocked is not None:
return blocked
from cowork_local.core import jira_tool from cowork_local.core import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")), out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
@@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Lấy chi tiết một issue Jira theo mã.""" """Lấy chi tiết một issue Jira theo mã."""
blocked = _network_refusal(ctx, "jira_get_issue")
if blocked is not None:
return blocked
from cowork_local.core import jira_tool from cowork_local.core import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", ""))) out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
+8 -4
View File
@@ -37,12 +37,16 @@ class ToolContext:
# None (default) = no limits, matching pre-existing behavior. # None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands" # Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) = # — the proxy-env block for shell commands (deps.py::network_blocked_env)
# AND a flat refusal from every NETWORK-capability tool, which reaches the
# net in-process where proxy env vars mean nothing. False (default) =
# unrestricted, matching pre-existing behavior. # unrestricted, matching pre-existing behavior.
block_network: bool = False block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network # Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way
# (reading a web page/share link for info is safe; running networked shell # around block_network: with the network blocked every NETWORK-capability
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch. # tool is refused first (fetch_tools.py::_network_refusal), so this flag only
# decides anything while the network is open. Defaults True; set from
# agent_security.allow_url_fetch.
allow_url_fetch: bool = True allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the # Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"]. # jira_* tools' ability to connect. Populated from config.data["jira"].
@@ -0,0 +1,392 @@
"""Spawn a shell command inside a Windows AppContainer with NO network capability.
Why this exists
---------------
The old "block network" for agent shell commands only pointed the proxy env
vars at a dead port (``core/deps.py::network_blocked_env``). Anything that
ignores proxies (``Invoke-WebRequest -NoProxy``, raw sockets, ``certutil``,
.NET ``WebClient``...) still reached the internet. An AppContainer token that
is granted no ``internetClient``/``privateNetworkClientServer`` capability is
refused by the kernel firewall for every outbound connection, loopback
included, no matter which tool makes it. No admin rights are needed.
An AppContainer can only open files whose ACL admits its SID (or ALL
APPLICATION PACKAGES). System32 and Program Files already do; the workdir and
the app's own Python install do not, so :func:`spawn` grants the profile SID
access to those folders first (an extra ACE, nothing is removed).
:class:`AppContainerProcess` quacks like ``subprocess.Popen`` for the subset
``core/deps.py::_run_cancellable_body`` uses (``pid``, ``stdout``/``stderr``
text streams, ``poll``/``wait``/``kill``/``returncode``), so the Stop button,
timeouts, Job Objects and resource limits keep working unchanged.
"""
from __future__ import annotations
import io
import locale
import os
import subprocess
import sys
import threading
from typing import Dict, Iterable, Optional
PROFILE_NAME = "cowork_local.agent_netblock"
_IS_WINDOWS = sys.platform == "win32"
if _IS_WINDOWS:
import ctypes
import msvcrt
from ctypes import wintypes
_k32 = ctypes.WinDLL("kernel32", use_last_error=True)
_adv = ctypes.WinDLL("advapi32", use_last_error=True)
_uenv = ctypes.WinDLL("userenv", use_last_error=True)
class _SECURITY_CAPABILITIES(ctypes.Structure):
_fields_ = [("AppContainerSid", ctypes.c_void_p), ("Capabilities", ctypes.c_void_p),
("CapabilityCount", wintypes.DWORD), ("Reserved", wintypes.DWORD)]
class _STARTUPINFOW(ctypes.Structure):
_fields_ = [("cb", wintypes.DWORD), ("lpReserved", wintypes.LPWSTR),
("lpDesktop", wintypes.LPWSTR), ("lpTitle", wintypes.LPWSTR),
("dwX", wintypes.DWORD), ("dwY", wintypes.DWORD),
("dwXSize", wintypes.DWORD), ("dwYSize", wintypes.DWORD),
("dwXCountChars", wintypes.DWORD), ("dwYCountChars", wintypes.DWORD),
("dwFillAttribute", wintypes.DWORD), ("dwFlags", wintypes.DWORD),
("wShowWindow", wintypes.WORD), ("cbReserved2", wintypes.WORD),
("lpReserved2", ctypes.c_void_p), ("hStdInput", wintypes.HANDLE),
("hStdOutput", wintypes.HANDLE), ("hStdError", wintypes.HANDLE)]
class _STARTUPINFOEXW(ctypes.Structure):
_fields_ = [("StartupInfo", _STARTUPINFOW), ("lpAttributeList", ctypes.c_void_p)]
class _PROCESS_INFORMATION(ctypes.Structure):
_fields_ = [("hProcess", wintypes.HANDLE), ("hThread", wintypes.HANDLE),
("dwProcessId", wintypes.DWORD), ("dwThreadId", wintypes.DWORD)]
class _SECURITY_ATTRIBUTES(ctypes.Structure):
_fields_ = [("nLength", wintypes.DWORD), ("lpSecurityDescriptor", ctypes.c_void_p),
("bInheritHandle", wintypes.BOOL)]
_uenv.CreateAppContainerProfile.restype = ctypes.c_long
_uenv.CreateAppContainerProfile.argtypes = [
wintypes.LPCWSTR, wintypes.LPCWSTR, wintypes.LPCWSTR, ctypes.c_void_p,
wintypes.DWORD, ctypes.POINTER(ctypes.c_void_p)]
_uenv.DeriveAppContainerSidFromAppContainerName.restype = ctypes.c_long
_uenv.DeriveAppContainerSidFromAppContainerName.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_void_p)]
_uenv.GetAppContainerFolderPath.restype = ctypes.c_long
_uenv.GetAppContainerFolderPath.argtypes = [
wintypes.LPCWSTR, ctypes.POINTER(ctypes.c_wchar_p)]
_adv.ConvertSidToStringSidW.restype = wintypes.BOOL
_adv.ConvertSidToStringSidW.argtypes = [ctypes.c_void_p, ctypes.POINTER(ctypes.c_wchar_p)]
_k32.InitializeProcThreadAttributeList.restype = wintypes.BOOL
_k32.InitializeProcThreadAttributeList.argtypes = [
ctypes.c_void_p, wintypes.DWORD, wintypes.DWORD, ctypes.POINTER(ctypes.c_size_t)]
_k32.UpdateProcThreadAttribute.restype = wintypes.BOOL
_k32.UpdateProcThreadAttribute.argtypes = [
ctypes.c_void_p, wintypes.DWORD, ctypes.c_size_t, ctypes.c_void_p,
ctypes.c_size_t, ctypes.c_void_p, ctypes.c_void_p]
_k32.DeleteProcThreadAttributeList.argtypes = [ctypes.c_void_p]
_k32.CreatePipe.restype = wintypes.BOOL
_k32.CreatePipe.argtypes = [ctypes.POINTER(wintypes.HANDLE), ctypes.POINTER(wintypes.HANDLE),
ctypes.POINTER(_SECURITY_ATTRIBUTES), wintypes.DWORD]
_k32.SetHandleInformation.argtypes = [wintypes.HANDLE, wintypes.DWORD, wintypes.DWORD]
_k32.CreateProcessW.restype = wintypes.BOOL
_k32.CreateProcessW.argtypes = [
wintypes.LPCWSTR, wintypes.LPWSTR, ctypes.c_void_p, ctypes.c_void_p, wintypes.BOOL,
wintypes.DWORD, ctypes.c_void_p, wintypes.LPCWSTR, ctypes.POINTER(_STARTUPINFOEXW),
ctypes.POINTER(_PROCESS_INFORMATION)]
_k32.ResumeThread.argtypes = [wintypes.HANDLE]
_k32.WaitForSingleObject.restype = wintypes.DWORD
_k32.WaitForSingleObject.argtypes = [wintypes.HANDLE, wintypes.DWORD]
_k32.GetExitCodeProcess.argtypes = [wintypes.HANDLE, ctypes.POINTER(wintypes.DWORD)]
_k32.TerminateProcess.argtypes = [wintypes.HANDLE, wintypes.UINT]
_k32.CloseHandle.argtypes = [wintypes.HANDLE]
_k32.GetStdHandle.restype = wintypes.HANDLE
_k32.OpenProcess.restype = wintypes.HANDLE
_ALREADY_EXISTS = ctypes.c_long(0x800700B7).value
_PROC_THREAD_ATTRIBUTE_HANDLE_LIST = 0x00020002
_PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES = 0x00020009
_EXTENDED_STARTUPINFO_PRESENT = 0x00080000
_CREATE_UNICODE_ENVIRONMENT = 0x00000400
_CREATE_NO_WINDOW = 0x08000000
_CREATE_SUSPENDED = 0x00000004
_STARTF_USESTDHANDLES = 0x00000100
_HANDLE_FLAG_INHERIT = 0x00000001
_STILL_ACTIVE = 259
_profile_lock = threading.Lock()
_profile: Dict[str, object] = {}
_granted: set = set()
class NetworkIsolationUnavailable(RuntimeError):
"""This machine cannot start a network-less process — callers must refuse to run."""
def is_supported() -> bool:
"""True on Windows builds that ship the AppContainer API (Windows 8+)."""
if not _IS_WINDOWS:
return False
try:
return bool(_uenv.CreateAppContainerProfile)
except AttributeError:
return False
def _profile_sid():
"""``(sid pointer, sid string, temp folder)`` of the shared no-network profile."""
with _profile_lock:
if _profile:
return _profile["sid"], _profile["sid_str"], _profile["temp"]
sid = ctypes.c_void_p()
hr = _uenv.CreateAppContainerProfile(PROFILE_NAME, "Cowork Local agent (no network)",
"Agent shell commands with the network blocked",
None, 0, ctypes.byref(sid))
if hr == _ALREADY_EXISTS:
hr = _uenv.DeriveAppContainerSidFromAppContainerName(PROFILE_NAME, ctypes.byref(sid))
if hr != 0 or not sid.value:
raise NetworkIsolationUnavailable(f"AppContainer profile error 0x{hr & 0xFFFFFFFF:08X}")
text = ctypes.c_wchar_p()
if not _adv.ConvertSidToStringSidW(sid, ctypes.byref(text)):
raise NetworkIsolationUnavailable("Could not read the AppContainer SID")
sid_str = text.value
folder = ctypes.c_wchar_p()
temp = ""
if _uenv.GetAppContainerFolderPath(sid_str, ctypes.byref(folder)) == 0 and folder.value:
temp = os.path.join(folder.value, "Temp")
os.makedirs(temp, exist_ok=True)
_profile.update(sid=sid, sid_str=sid_str, temp=temp)
return sid, sid_str, temp
_PERMS = {"write": "(OI)(CI)(M)", "read": "(OI)(CI)(RX)", "read_here": "(OI)(NP)(RX)"}
def _qt_package_dir() -> str:
"""Folder of the installed PySide6/Qt binaries ('' if PySide6 is absent)."""
try:
import importlib.util
spec = importlib.util.find_spec("PySide6")
except (ImportError, ValueError):
return ""
return os.path.dirname(spec.origin) if spec and spec.origin else ""
def _covers(folder: str, target: str) -> bool:
"""True if ``target`` is ``folder`` itself or lies somewhere below it."""
if not folder or not target:
return False
folder, target = os.path.normcase(folder), os.path.normcase(target)
return target == folder or target.startswith(folder.rstrip("\\/") + os.sep)
def _icacls(*args: str) -> subprocess.CompletedProcess:
return subprocess.run(["icacls", *args], capture_output=True, text=True,
creationflags=_CREATE_NO_WINDOW)
def grant_access(path: str, sid_str: str, mode: str) -> None:
"""Let the AppContainer SID open ``path``.
``mode`` is ``"write"``/``"read"`` (inherited by everything below) or
``"read_here"`` (this folder and the files directly in it, no deeper).
An inherited ACE must never reach the Qt WebEngine binaries: Chromium's
sandboxed render process then fails to load Qt6WebEngineCore.dll
(STATUS_DLL_NOT_FOUND) and every web view in the app goes blank. A folder
that contains the PySide6 install is therefore refused.
"""
path = os.path.abspath(path)
key = (os.path.normcase(path), mode)
if key in _granted or not os.path.exists(path):
return
if mode != "read_here" and _covers(path, _qt_package_dir()):
raise NetworkIsolationUnavailable(
f"Refusing to sandbox a folder that contains the app's Qt runtime: {path}")
perm = _PERMS[mode]
listing = (_icacls(path).stdout or "").lower()
if f"{sid_str}:{perm}".lower() not in listing:
done = _icacls(path, "/grant", f"*{sid_str}:{perm}", "/Q", "/C")
if done.returncode != 0:
raise NetworkIsolationUnavailable(
f"Could not grant the sandbox access to {path}: {(done.stderr or done.stdout).strip()}")
_granted.add(key)
def _repair_inherited_grant(path: str, sid_str: str) -> None:
"""Drop an inherited grant that an earlier build put on the app's venv."""
key = (os.path.normcase(os.path.abspath(path)), "repaired")
if key in _granted or not os.path.isdir(path):
return
listing = (_icacls(path).stdout or "").lower()
if f"{sid_str}:(oi)(ci)".lower() in listing:
_icacls(path, "/remove:g", f"*{sid_str}", "/Q", "/C")
_granted.add(key)
def _interpreter_grants():
"""``(folder, mode)`` pairs that let the sandbox run the app's Python.
The base install is read-only and holds no Qt; a venv only needs its root
(``pyvenv.cfg``) and ``Scripts`` — never ``Lib/site-packages``.
"""
qt_dir = _qt_package_dir()
if _covers(sys.base_prefix, qt_dir):
# PySide6 lives in the base install itself: expose only the executable
# and the compiled stdlib modules, never the tree holding Qt.
grants = [(sys.base_prefix, "read_here"), (os.path.join(sys.base_prefix, "DLLs"), "read")]
else:
grants = [(sys.base_prefix, "read")]
if os.path.normcase(sys.prefix) != os.path.normcase(sys.base_prefix):
grants += [(sys.prefix, "read_here"), (os.path.join(sys.prefix, "Scripts"), "read")]
return [(folder, mode) for folder, mode in grants
if mode == "read_here" or not _covers(folder, qt_dir)]
def _env_block(env: Dict[str, str]) -> ctypes.Array:
"""Sorted, double-NUL-terminated UTF-16 environment block for CreateProcessW."""
items = sorted(env.items(), key=lambda kv: kv[0].upper())
text = "".join(f"{k}={v}\0" for k, v in items if k and "=" not in k) + "\0"
return ctypes.create_unicode_buffer(text, len(text))
def _pipe():
"""Anonymous pipe; only the child's (write) end is inheritable."""
sa = _SECURITY_ATTRIBUTES(ctypes.sizeof(_SECURITY_ATTRIBUTES), None, True)
read, write = wintypes.HANDLE(), wintypes.HANDLE()
if not _k32.CreatePipe(ctypes.byref(read), ctypes.byref(write), ctypes.byref(sa), 0):
raise ctypes.WinError(ctypes.get_last_error())
_k32.SetHandleInformation(read, _HANDLE_FLAG_INHERIT, 0)
return read, write
class AppContainerProcess:
"""The ``subprocess.Popen`` subset that ``deps._run_cancellable_body`` relies on."""
def __init__(self, handle, pid: int, stdout: io.TextIOBase, stderr: io.TextIOBase):
"""Wrap an already-started process handle and its two output streams."""
self._handle = handle
self.pid = pid
self.stdout = stdout
self.stderr = stderr
self.returncode: Optional[int] = None
def poll(self) -> Optional[int]:
"""Exit code if the process has finished, else None."""
if self.returncode is None and self._handle:
code = wintypes.DWORD()
if _k32.GetExitCodeProcess(self._handle, ctypes.byref(code)) and code.value != _STILL_ACTIVE:
self.returncode = ctypes.c_int32(code.value).value
_k32.CloseHandle(self._handle)
self._handle = None
return self.returncode
def wait(self, timeout: Optional[float] = None) -> int:
"""Block until exit; raise ``subprocess.TimeoutExpired`` like Popen does."""
if self.returncode is None and self._handle:
ms = 0xFFFFFFFF if timeout is None else int(timeout * 1000)
if _k32.WaitForSingleObject(self._handle, ms) != 0:
raise subprocess.TimeoutExpired("appcontainer", timeout)
return self.poll()
def kill(self) -> None:
"""Terminate the process (the Job Object in deps kills its children)."""
if self.returncode is None and self._handle:
_k32.TerminateProcess(self._handle, 1)
def communicate(self, timeout: Optional[float] = None):
"""Read both streams to the end and wait; returns ``(stdout, stderr)``."""
chunks: Dict[str, str] = {}
def _drain(name, stream):
chunks[name] = stream.read()
readers = [threading.Thread(target=_drain, args=(n, s), daemon=True)
for n, s in (("out", self.stdout), ("err", self.stderr))]
for t in readers:
t.start()
for t in readers:
t.join(timeout)
self.wait(timeout)
return chunks.get("out", ""), chunks.get("err", "")
def spawn(command: str, cwd: Optional[str], env: Optional[Dict[str, str]],
readable_dirs: Iterable[str] = ()) -> AppContainerProcess:
"""Start ``cmd.exe /c command`` in the no-network AppContainer.
Raises :class:`NetworkIsolationUnavailable` when that cannot be done —
callers must then refuse the command rather than run it with the network on.
"""
if not is_supported():
raise NetworkIsolationUnavailable("AppContainer is only available on Windows")
sid, sid_str, temp = _profile_sid()
cwd = os.path.abspath(cwd or os.getcwd())
_repair_inherited_grant(sys.prefix, sid_str)
grant_access(cwd, sid_str, "write")
for folder, mode in [*_interpreter_grants(), *((d, "read") for d in readable_dirs if d)]:
grant_access(folder, sid_str, mode)
child_env = dict(os.environ if env is None else env)
if temp:
child_env["TEMP"] = child_env["TMP"] = temp
child_env.setdefault("PYTHONIOENCODING", "utf-8")
env_block = _env_block(child_env)
out_r, out_w = _pipe()
err_r, err_w = _pipe()
handles = (wintypes.HANDLE * 2)(out_w, err_w)
caps = _SECURITY_CAPABILITIES(sid, None, 0, 0)
size = ctypes.c_size_t()
_k32.InitializeProcThreadAttributeList(None, 2, 0, ctypes.byref(size))
attr = ctypes.create_string_buffer(size.value)
pi = _PROCESS_INFORMATION()
try:
if not (_k32.InitializeProcThreadAttributeList(attr, 2, 0, ctypes.byref(size))
and _k32.UpdateProcThreadAttribute(
attr, 0, _PROC_THREAD_ATTRIBUTE_SECURITY_CAPABILITIES, ctypes.byref(caps),
ctypes.sizeof(caps), None, None)
and _k32.UpdateProcThreadAttribute(
attr, 0, _PROC_THREAD_ATTRIBUTE_HANDLE_LIST, handles,
ctypes.sizeof(handles), None, None)):
raise NetworkIsolationUnavailable(str(ctypes.WinError(ctypes.get_last_error())))
si = _STARTUPINFOEXW()
si.StartupInfo.cb = ctypes.sizeof(si)
si.StartupInfo.dwFlags = _STARTF_USESTDHANDLES
si.StartupInfo.hStdOutput = out_w
si.StartupInfo.hStdError = err_w
si.lpAttributeList = ctypes.addressof(attr)
comspec = os.environ.get("COMSPEC") or r"C:\Windows\System32\cmd.exe"
cmdline = ctypes.create_unicode_buffer(f'"{comspec}" /d /s /c "{command}"')
flags = (_EXTENDED_STARTUPINFO_PRESENT | _CREATE_UNICODE_ENVIRONMENT
| _CREATE_NO_WINDOW | _CREATE_SUSPENDED)
if not _k32.CreateProcessW(None, cmdline, None, None, True, flags,
ctypes.addressof(env_block), cwd,
ctypes.byref(si), ctypes.byref(pi)):
raise NetworkIsolationUnavailable(
f"Could not start the sandboxed command: {ctypes.WinError(ctypes.get_last_error())}")
_k32.ResumeThread(pi.hThread)
_k32.CloseHandle(pi.hThread)
except BaseException:
for h in (out_r, err_r):
_k32.CloseHandle(h)
raise
finally:
_k32.DeleteProcThreadAttributeList(attr)
_k32.CloseHandle(out_w)
_k32.CloseHandle(err_w)
def _stream(handle) -> io.TextIOBase:
fd = msvcrt.open_osfhandle(handle.value, os.O_RDONLY)
return io.TextIOWrapper(io.FileIO(fd, "rb"), encoding=locale.getpreferredencoding(False),
errors="replace")
return AppContainerProcess(pi.hProcess, pi.dwProcessId, _stream(out_r), _stream(err_r))
__all__ = ["AppContainerProcess", "NetworkIsolationUnavailable", "PROFILE_NAME",
"grant_access", "is_supported", "spawn"]
@@ -0,0 +1,46 @@
"""Start a shell command that the operating system keeps off the network.
Used whenever "Block network" is on for agent ``run_command`` calls and for
scheduled script tasks. The contract is fail-closed: if isolation cannot be
set up, :class:`NetworkIsolationUnavailable` is raised and the caller refuses
the command instead of running it with the network open.
* Windows: an AppContainer with no network capability
(:mod:`.appcontainer_process`).
* macOS: ``sandbox-exec`` with a profile that denies every network operation.
* Linux: ``unshare --net`` in a new user namespace (an empty network namespace
has only a downed loopback). If unprivileged namespaces are disabled,
``unshare`` itself fails and the command never runs.
"""
from __future__ import annotations
import shutil
import subprocess
import sys
from typing import Dict, Optional
from .appcontainer_process import NetworkIsolationUnavailable
_MACOS_PROFILE = "(version 1)(allow default)(deny network*)"
def spawn_without_network(command: str, cwd: Optional[str], env: Optional[Dict[str, str]]):
"""A ``Popen``-like process running ``command`` through the shell, with no network."""
if sys.platform == "win32":
from . import appcontainer_process
return appcontainer_process.spawn(command, cwd, env)
if sys.platform == "darwin" and shutil.which("sandbox-exec"):
argv = ["sandbox-exec", "-p", _MACOS_PROFILE, "/bin/sh", "-c", command]
elif sys.platform.startswith("linux") and shutil.which("unshare"):
argv = ["unshare", "--user", "--map-root-user", "--net", "/bin/sh", "-c", command]
else:
raise NetworkIsolationUnavailable(
"No network isolation is available on this system (needs AppContainer, "
"sandbox-exec or unshare).")
return subprocess.Popen(argv, cwd=cwd, env=env, stdout=subprocess.PIPE,
stderr=subprocess.PIPE, text=True, bufsize=1,
start_new_session=True)
__all__ = ["NetworkIsolationUnavailable", "spawn_without_network"]
+31
View File
@@ -0,0 +1,31 @@
"""Tiny opt-in performance tracing helpers.
Tracing is disabled by default and emits only timings/counts, never prompts,
credentials, file contents, or provider payloads.
"""
from __future__ import annotations
import logging
import os
import time
from contextlib import contextmanager
_LOG = logging.getLogger("cowork.performance")
def enabled() -> bool:
return os.environ.get("COWORK_PERF_TRACE", "").strip().lower() in {"1", "true", "yes"}
@contextmanager
def span(name: str, **fields):
if not enabled():
yield
return
started = time.perf_counter()
try:
yield
finally:
elapsed = (time.perf_counter() - started) * 1000.0
safe = " ".join(f"{k}={v}" for k, v in fields.items())
_LOG.info("perf %s %.1fms%s", name, elapsed, f" {safe}" if safe else "")
+10 -5
View File
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from cowork_local.i18n import tr from cowork_local.i18n import bind_dynamic, bind_tip, tr
from cowork_local.theme import current_palette from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon from cowork_local.ui.icons import icon
@@ -57,7 +57,7 @@ class AudioRecorderWidget(QWidget):
# Record / Stop toggle button # Record / Stop toggle button
self.record_btn = QPushButton() self.record_btn = QPushButton()
self.record_btn.setIcon(icon("microphone")) self.record_btn.setIcon(icon("microphone"))
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note") bind_dynamic(self.record_btn, self._sync_record_tip)
self.record_btn.setFixedSize(32, 32) self.record_btn.setFixedSize(32, 32)
self.record_btn.clicked.connect(self.toggle_recording) self.record_btn.clicked.connect(self.toggle_recording)
layout.addWidget(self.record_btn) layout.addWidget(self.record_btn)
@@ -78,7 +78,7 @@ class AudioRecorderWidget(QWidget):
self.cancel_btn = QPushButton() self.cancel_btn = QPushButton()
self.cancel_btn.setIcon(icon("x")) self.cancel_btn.setIcon(icon("x"))
self.cancel_btn.setToolTip("Cancel recording") bind_tip(self.cancel_btn, "chat.record_audio_cancel")
self.cancel_btn.setFixedSize(24, 24) self.cancel_btn.setFixedSize(24, 24)
self.cancel_btn.clicked.connect(self.cancel_recording) self.cancel_btn.clicked.connect(self.cancel_recording)
status_layout.addWidget(self.cancel_btn) status_layout.addWidget(self.cancel_btn)
@@ -106,7 +106,7 @@ class AudioRecorderWidget(QWidget):
self.timer_label.setText("00:00") self.timer_label.setText("00:00")
self.status_container.setVisible(True) self.status_container.setVisible(True)
self.record_btn.setIcon(icon("square")) self.record_btn.setIcon(icon("square"))
self.record_btn.setToolTip("Stop Recording") self._sync_record_tip()
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;") self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
self._timer.start() self._timer.start()
self.recording_started.emit() self.recording_started.emit()
@@ -137,7 +137,12 @@ class AudioRecorderWidget(QWidget):
self.status_container.setVisible(False) self.status_container.setVisible(False)
self.record_btn.setIcon(icon("microphone")) self.record_btn.setIcon(icon("microphone"))
self.record_btn.setStyleSheet("") self.record_btn.setStyleSheet("")
self.record_btn.setToolTip("Record Voice Note") self._sync_record_tip()
def _sync_record_tip(self) -> None:
"""Tooltip nút ghi âm nói việc nó sẽ làm tiếp, theo trạng thái hiện tại."""
self.record_btn.setToolTip(tr("chat.record_audio_stop" if self._is_recording
else "chat.record_audio_start"))
def _on_tick(self) -> None: def _on_tick(self) -> None:
"""Update recording duration display every second.""" """Update recording duration display every second."""
+14 -1
View File
@@ -115,11 +115,24 @@ class ChatAgentsMixin:
if err: if err:
self.status_message.emit(tr("chatpanel.agent_list_error", err=err)) self.status_message.emit(tr("chatpanel.agent_list_error", err=err))
# Model discovery can involve a provider/network request. Constructing
# the chat panel during startup must not wait for it; schedule it after
# the first event-loop turn so the initial shell can paint immediately.
from PySide6.QtCore import QTimer
if getattr(self, "_agent_refresh_pending", False):
return
self._agent_refresh_pending = True
def start_worker() -> None:
self._agent_refresh_pending = False
w = AgentWorker(job) w = AgentWorker(job)
w.finished_ok.connect(done) w.finished_ok.connect(done)
self._agent_worker = w self._agent_worker = w
w.start() w.start()
QTimer.singleShot(0, start_worker)
def _populate_agents(self, models, keep: str) -> None: def _populate_agents(self, models, keep: str) -> None:
"""Đổ danh sách vào bộ chọn Agent. """Đổ danh sách vào bộ chọn Agent.
@@ -141,7 +154,7 @@ class ChatAgentsMixin:
if not items and self.agent_combo.count() == 0: if not items and self.agent_combo.count() == 0:
# No models found and none configured — placeholder with data=None so # No models found and none configured — placeholder with data=None so
# we fall back to the provider's default model (never a fake name). # we fall back to the provider's default model (never a fake name).
self.agent_combo.addItem("(provider default)", None) self.agent_combo.addItem(tr("chat.provider_default_item"), None)
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}" keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
if getattr(self, "_admin_agent", None) is not None else keep) if getattr(self, "_admin_agent", None) is not None else keep)
idx = self.agent_combo.findData(keep_data) if keep_data else -1 idx = self.agent_combo.findData(keep_data) if keep_data else -1
+10 -1
View File
@@ -51,6 +51,8 @@ class MessageBubble(QFrame):
super().__init__() super().__init__()
self.role = role self.role = role
self._text = "" self._text = ""
self._stream_pending = False
self._render_count = 0
self._collapsible = collapsible self._collapsible = collapsible
self._title = title self._title = title
self._head = None self._head = None
@@ -164,12 +166,20 @@ class MessageBubble(QFrame):
def append_delta(self, delta: str) -> None: def append_delta(self, delta: str) -> None:
"""Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown.""" """Nối thêm một mẩu văn bản đang phát dần từ model rồi vẽ lại dạng markdown."""
self._text += delta self._text += delta
if not self._stream_pending:
self._stream_pending = True
QTimer.singleShot(40, self.flush_stream)
def flush_stream(self) -> None:
if self._stream_pending:
self._stream_pending = False
self.set_markdown(self._text) self.set_markdown(self._text)
def set_markdown(self, text: str) -> None: def set_markdown(self, text: str) -> None:
"""Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao.""" """Đặt toàn bộ nội dung, hiển thị dạng markdown, rồi co giãn lại chiều cao."""
self._text = text self._text = text
self.body.setMarkdown(text) self.body.setMarkdown(text)
self._render_count += 1
self._autosize() self._autosize()
if self._collapsible: if self._collapsible:
self._update_head() self._update_head()
@@ -393,4 +403,3 @@ class ChatView(QScrollArea):
ChatHistoryWidget = ChatView ChatHistoryWidget = ChatView
__all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"] __all__ = ["ChatView", "ChatHistoryWidget", "MessageBubble"]
+2 -2
View File
@@ -11,9 +11,9 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox
from ...core.worker import AgentWorker from ...core.worker import AgentWorker
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import confirm
class ChatSessionMixin: class ChatSessionMixin:
@@ -308,7 +308,7 @@ class ChatSessionMixin:
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview) prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
else: else:
prompt = tr("chatpanel.delete_confirm_plain") prompt = tr("chatpanel.delete_confirm_plain")
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes: if not confirm(self, tr("chatpanel.delete_confirm_title"), prompt):
return return
for bubble in turn.get("bubbles", []): for bubble in turn.get("bubbles", []):
bubble.setParent(None) bubble.setParent(None)
+13 -4
View File
@@ -34,6 +34,7 @@ from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu
from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node
from ...i18n import tr
from ...theme import current_palette from ...theme import current_palette
from .canvas_geometry import _elide, _rounded_path from .canvas_geometry import _elide, _rounded_path
@@ -199,6 +200,14 @@ class _NodeItem(QGraphicsObject):
e.accept() e.accept()
return return
super().mousePressEvent(e) super().mousePressEvent(e)
if self.isSelected():
# itemChange() only emits node_selected when the SELECTION STATE
# actually flips (ItemSelectedHasChanged) — clicking a node that
# was already selected (e.g. left selected when a run started)
# never re-fires it, so the property panel silently kept showing
# stale data and looked "locked" while the node ran. Emit
# explicitly on every click so the panel always reloads.
self.canvas.node_selected.emit(self.node.id)
def mouseMoveEvent(self, e): def mouseMoveEvent(self, e):
"""Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ.""" """Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ."""
@@ -225,9 +234,9 @@ class _NodeItem(QGraphicsObject):
def contextMenuEvent(self, e): def contextMenuEvent(self, e):
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước.""" """Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
menu = QMenu() menu = QMenu()
a_add = menu.addAction("+ Add next step") a_add = menu.addAction("+ " + tr("co4e.canvas_add_next"))
a_conn = menu.addAction("→ Connect from here") a_conn = menu.addAction("→ " + tr("co4e.canvas_connect_from"))
a_del = menu.addAction("🗑 Delete step") a_del = menu.addAction("🗑 " + tr("co4e.delete_step"))
chosen = menu.exec(e.screenPos()) chosen = menu.exec(e.screenPos())
if chosen is a_add: if chosen is a_add:
self.canvas.add_step_below(self.node.id) self.canvas.add_step_below(self.node.id)
@@ -334,7 +343,7 @@ class _EdgeItem(QGraphicsPathItem):
def contextMenuEvent(self, e): def contextMenuEvent(self, e):
"""Menu chuột phải trên đường nối: xoá liên kết.""" """Menu chuột phải trên đường nối: xoá liên kết."""
menu = QMenu() menu = QMenu()
act_del = menu.addAction("🗑 Delete connection") act_del = menu.addAction("🗑 " + tr("co4e.canvas_delete_edge"))
if menu.exec(e.screenPos()) is act_del: if menu.exec(e.screenPos()) is act_del:
self.canvas.delete_edge(self.edge) self.canvas.delete_edge(self.edge)
e.accept() e.accept()
+9
View File
@@ -273,6 +273,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
item.status = status item.status = status
item.update() item.update()
def node_status(self, node_id: str) -> str:
"""Trạng thái chạy hiện tại của một node — "idle" nếu không tìm thấy.
Dùng để quyết định có khóa bảng thuộc tính bên phải hay không khi
người dùng chọn node (xem ``StepConfigPanel.set_locked``).
"""
item = self._nodes.get(node_id)
return item.status if item is not None else "idle"
def reset_statuses(self) -> None: def reset_statuses(self) -> None:
"""Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng.""" """Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng."""
for it in self._nodes.values(): for it in self._nodes.values():
+5
View File
@@ -101,6 +101,11 @@ class Co4EFlowTabsMixin:
self.center_stack.setCurrentIndex(1) self.center_stack.setCurrentIndex(1)
self._sync_runs_toggle(False) self._sync_runs_toggle(False)
self._apply_workflow(self._flows[flow_idx]) self._apply_workflow(self._flows[flow_idx])
# _apply_workflow() rebuilds the canvas from wf.nodes/edges, which
# resets every node's live status to "idle" — without this, coming
# back to a flow that's still running (e.g. from the Runs page)
# shows every node as idle even though it's actually mid-run.
self._reflect_active_run(self._flows[flow_idx].id)
def _sync_runs_toggle(self, on: bool) -> None: def _sync_runs_toggle(self, on: bool) -> None:
"""Keep the Runs toggle showing which page is up, however it got there """Keep the Runs toggle showing which page is up, however it got there
(a double-click in the runs table also switches pages).""" (a double-click in the runs table also switches pages)."""
+12 -4
View File
@@ -13,10 +13,11 @@ import re
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem from PySide6.QtWidgets import QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from ...core import co4e from ...core import co4e
from ...i18n import tr from ...i18n import tr
from ...theme import current_palette from ...theme import current_palette
from .co4e_workflow_crud import _LOCKED_NODE_STATUSES
class Co4ERunsMixin: class Co4ERunsMixin:
@@ -155,7 +156,14 @@ class Co4ERunsMixin:
t = ev.get("type") t = ev.get("type")
if t == "node_status": if t == "node_status":
if shown: if shown:
self.canvas.update_node_status(ev.get("node_id"), ev.get("status")) nid = ev.get("node_id")
self.canvas.update_node_status(nid, ev.get("status"))
# If the panel is showing THIS node right now (e.g. it was
# idle and the user had it open when the run started), keep
# the lock in sync instead of waiting for the next click.
if nid == getattr(self.config, "_node_id", None):
self.config.set_locked(
self.canvas.node_status(nid) in _LOCKED_NODE_STATUSES)
elif t == "node_output": elif t == "node_output":
if run_wf is not None: if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "") self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
@@ -329,8 +337,8 @@ class Co4ERunsMixin:
h = self.manager.get(run_id) h = self.manager.get(run_id)
if h is None: if h is None:
return return
from PySide6.QtWidgets import QInputDialog from ...ui.dialog_buttons import ask_text
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"), new, ok = ask_text(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name) tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip() new = (new or "").strip()
if not ok or not new or new == h.name: if not ok or not new or new == h.name:
+12 -3
View File
@@ -8,12 +8,16 @@ from __future__ import annotations
import re import re
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu from PySide6.QtWidgets import QMenu
from ...core import co4e from ...core import co4e
from ...core.co4e import STEP_RUNNING
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names from ...presentation.co4e.co4e_chat_view import _skill_names
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
class Co4EWorkflowCrudMixin: class Co4EWorkflowCrudMixin:
"""Phần tạo/mở/lưu/xoá luồng của Co4E Studio. """Phần tạo/mở/lưu/xoá luồng của Co4E Studio.
@@ -115,7 +119,7 @@ class Co4EWorkflowCrudMixin:
wf = co4e.get_workflow(ident) wf = co4e.get_workflow(ident)
if wf is None: if wf is None:
return return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"), name, ok = ask_text(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name) text=wf.name)
name = (name or "").strip() name = (name or "").strip()
if not ok or not name: if not ok or not name:
@@ -163,10 +167,15 @@ class Co4EWorkflowCrudMixin:
self.canvas.add_palette_step(co4e.Step(label="New Step"), self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center())) self.canvas.mapToScene(self.canvas.rect().center()))
def _on_node_selected(self, node_id: str) -> None: def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.""" """Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
vẫn sửa lại được bình thường.
"""
for n in self.canvas.nodes(): for n in self.canvas.nodes():
if n.id == node_id: if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names()) self.config.load_step(node_id, n.data, _skill_names())
self.config.set_locked(self.canvas.node_status(node_id) in _LOCKED_NODE_STATUSES)
if self._config_collapsed: if self._config_collapsed:
self._toggle_config() self._toggle_config()
return return
@@ -24,20 +24,21 @@ thứ tự kế thừa không ảnh hưởng hành vi (khác trường hợp
``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có ``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có
override trùng tên). override trùng tên).
Import trong từng method giữ nguyên y hệt bản gốc (kể cả các import cục bộ có Import trong từng method giữ nguyên y hệt bản gốc — chỉ số cấp `..` được nâng
vẻ thừa như ``from PySide6.QtWidgets import QInputDialog`` lặp lại bên trong lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
``_add_subagent``/``_edit_subagent`` dù đã có ở top-level) — chỉ số cấp `..` ``presentation/co4e/`` (cách gốc 3 cấp). Riêng các lời gọi ``QInputDialog``
được nâng lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang đã chuyển sang ``ui.dialog_buttons``: hàm tĩnh của Qt tự dựng hộp thoại bên
``presentation/co4e/`` (cách gốc 3 cấp). trong nên nút "Cancel" của nó luôn là tiếng Anh.
""" """
from __future__ import annotations from __future__ import annotations
from typing import List from typing import List
from PySide6.QtWidgets import QInputDialog, QListWidgetItem from PySide6.QtWidgets import QListWidgetItem
from ...core.co4e import SubAgent from ...core.co4e import SubAgent
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import ask_item, ask_multiline, ask_text
class _StepConfigActionsMixin: class _StepConfigActionsMixin:
@@ -67,14 +68,12 @@ class _StepConfigActionsMixin:
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó).""" """Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
if self._step is None: if self._step is None:
return return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names() names = self._available_agent_names()
if names: if names:
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"), name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one names, 0, True) # editable: can type a new one
else: else:
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent")) name, ok = ask_text(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
name = (name or "").strip() name = (name or "").strip()
if not ok or not name: if not ok or not name:
return return
@@ -89,12 +88,10 @@ class _StepConfigActionsMixin:
row = self.sub_list.row(item) row = self.sub_list.row(item)
if not (0 <= row < len(self._step.sub_agents)): if not (0 <= row < len(self._step.sub_agents)):
return return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names() names = self._available_agent_names()
cur = self._step.sub_agents[row].agent cur = self._step.sub_agents[row].agent
start = names.index(cur) if cur in names else 0 start = names.index(cur) if cur in names else 0
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"), name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names or [cur], start, True) names or [cur], start, True)
name = (name or "").strip() name = (name or "").strip()
if ok and name: if ok and name:
@@ -151,7 +148,7 @@ class _StepConfigActionsMixin:
role = self.role_edit.text().strip() role = self.role_edit.text().strip()
if not name and not role: if not name and not role:
return return
hint, ok = QInputDialog.getMultiLineText( hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label")) self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok: if not ok:
return return
+28 -5
View File
@@ -38,7 +38,7 @@ from PySide6.QtWidgets import (
) )
from ...config import PROVIDER_LABELS from ...config import PROVIDER_LABELS
from ...core.co4e import PERMISSION_PRESETS, Step from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, Step
from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr
from ...ui.icons import icon, icon_picker_combo from ...ui.icons import icon, icon_picker_combo
from .node_property_actions_mixin import _StepConfigActionsMixin from .node_property_actions_mixin import _StepConfigActionsMixin
@@ -65,6 +65,8 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self._step: Optional[Step] = None self._step: Optional[Step] = None
self._node_id = "" self._node_id = ""
self._loading = False self._loading = False
self._ctx_available = ctx is not None
self._locked = False
self.setWidgetResizable(True) self.setWidgetResizable(True)
host = QWidget() host = QWidget()
self.setWidget(host) self.setWidget(host)
@@ -77,7 +79,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# (skills/files/sub-agents). No tabs/accordion: every group's border # (skills/files/sub-agents). No tabs/accordion: every group's border
# and heading are what separate it from its neighbours, and all three # and heading are what separate it from its neighbours, and all three
# are on screen (or one scroll away) at once. # are on screen (or one scroll away) at once.
form, _basic_card = _add_section(outer, tr("co4e.tab_basic")) form, _basic_card = _add_section(outer, "co4e.tab_basic")
self.label_edit = QLineEdit() self.label_edit = QLineEdit()
self.label_edit.textChanged.connect(self._on_edit) self.label_edit.textChanged.connect(self._on_edit)
@@ -122,7 +124,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.context_edit.textChanged.connect(self._on_edit) self.context_edit.textChanged.connect(self._on_edit)
form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit) form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm")) form2, _model_card = _add_section(outer, "co4e.tab_model_perm")
model_row = QHBoxLayout() model_row = QHBoxLayout()
self.model_combo = QComboBox() self.model_combo = QComboBox()
@@ -161,7 +163,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
vrow = QWidget(); vrow.setLayout(verify_row) vrow = QWidget(); vrow.setLayout(verify_row)
form2.addRow("", vrow) form2.addRow("", vrow)
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files")) form3, _skills_card = _add_section(outer, "co4e.tab_skills_files")
# Skills checklist (registry skills) # Skills checklist (registry skills)
self.skills_list = QListWidget() self.skills_list = QListWidget()
@@ -191,7 +193,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# Skills & Tệp, since it's really a distinct group, just one that # Skills & Tệp, since it's really a distinct group, just one that
# only applies to parallel-variant steps. load_step() hides the whole # only applies to parallel-variant steps. load_step() hides the whole
# card for a non-parallel step (see is_par below). # card for a non-parallel step (see is_par below).
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents")) form4, self._parallel_card = _add_section(outer, "co4e.f_subagents")
self.sub_list = QListWidget() self.sub_list = QListWidget()
self.sub_list.setMaximumHeight(90) self.sub_list.setMaximumHeight(90)
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
@@ -289,6 +291,27 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.sub_list.addItem(sub.agent) self.sub_list.addItem(sub.agent)
self._loading = False self._loading = False
def set_locked(self, locked: bool) -> None:
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
phải cả panel.
"""
self._locked = locked
editable = not locked
for w in (self.label_edit, self.role_edit, self.icon_edit,
self.instructions_edit, self.context_edit,
self.model_combo, self.perm_combo, self.verify_chk,
self.rounds_spin, self.skills_list,
self.attach_add_btn, self.attach_del_btn,
self.sub_add_btn, self.sub_del_btn, self.sub_list):
w.setEnabled(editable)
self.gen_btn.setEnabled(editable and self._ctx_available)
self.load_models_btn.setEnabled(editable and self._ctx_available)
def clear_step(self) -> None: def clear_step(self) -> None:
"""Xoá bảng khi không có bước nào được chọn.""" """Xoá bảng khi không có bước nào được chọn."""
self._step = None self._step = None
+16 -4
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
from ...i18n import bind_dynamic, tr
from ...theme import current_palette from ...theme import current_palette
_SECTION_ANIM_MS = 180 _SECTION_ANIM_MS = 180
@@ -64,7 +65,7 @@ class _SectionHeader(QLabel):
super().showEvent(event) super().showEvent(event)
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]: def _add_section(outer: QVBoxLayout, title_key: str) -> tuple[QFormLayout, QWidget]:
"""One group of fields, collapsed to just its heading by default and """One group of fields, collapsed to just its heading by default and
independently expandable, so a long step config reads as a short list of independently expandable, so a long step config reads as a short list of
group names until you open the one you need. Deliberately bare — no card group names until you open the one you need. Deliberately bare — no card
@@ -74,7 +75,11 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
the group's rows to ``form``; ``card`` is the whole section (header + the group's rows to ``form``; ``card`` is the whole section (header +
body) — hide it to remove the group entirely (e.g. for a section that body) — hide it to remove the group entirely (e.g. for a section that
only applies to some steps), rather than hiding individual rows inside only applies to some steps), rather than hiding individual rows inside
an always-visible header.""" an always-visible header.
Nhận KHOÁ dịch, không nhận chuỗi đã dịch: nhãn mục do hàm này tự dựng nên
nơi gọi không giữ tham chiếu nào để áp lại: truyền ``tr(...)`` vào đây thì
bốn tiêu đề đứng nguyên ở ngôn ngữ lúc dựng panel."""
p = current_palette() p = current_palette()
card = QWidget() card = QWidget()
card_lay = QVBoxLayout(card) card_lay = QVBoxLayout(card)
@@ -96,7 +101,6 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
# diacritics. # diacritics.
header.ensurePolished() header.ensurePolished()
header.setFixedHeight(header.fontMetrics().height()) header.setFixedHeight(header.fontMetrics().height())
header.setText(f"▶ {title}")
card_lay.addWidget(header) card_lay.addWidget(header)
body = QWidget() body = QWidget()
@@ -112,6 +116,14 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
is_open = False is_open = False
def _sync_header() -> None:
"""Nhãn mục: dấu gập/mở hiện tại + tiêu đề theo ngôn ngữ đang chọn."""
header.setText(f"{'▼' if is_open else '▶'} {tr(title_key)}")
# Ràng buộc ĐỘNG chứ không bind cứng một chuỗi: nhãn này mang cả trạng thái
# gập/mở, nên bind cứng sẽ trả nó về ▶ mỗi lần người dùng đổi ngôn ngữ.
bind_dynamic(header, _sync_header)
def _on_finished() -> None: def _on_finished() -> None:
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều """Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
trường không bị cắt cụt. trường không bị cắt cụt.
@@ -130,7 +142,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng.""" """Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
nonlocal is_open nonlocal is_open
is_open = not is_open is_open = not is_open
header.setText(f"{'▼' if is_open else '▶'} {title}") _sync_header()
anim.stop() anim.stop()
if is_open: if is_open:
body.setVisible(True) body.setVisible(True)
+3 -3
View File
@@ -116,9 +116,9 @@ class HabitsWidget(QWidget):
"""Apply an AI-suggested cost-saving strategy AFTER the user """Apply an AI-suggested cost-saving strategy AFTER the user
approves: turn on auto-compress and compress earlier (lower approves: turn on auto-compress and compress earlier (lower
threshold) + compress content before sending it to the agent.""" threshold) + compress content before sending it to the agent."""
from PySide6.QtWidgets import QMessageBox from ...ui.dialog_buttons import confirm
if QMessageBox.question(self, tr("dashboard.strategy_title"), if not confirm(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")) != QMessageBox.Yes: tr("dashboard.strategy_confirm")):
return return
cx = self.ctx.config.data.setdefault("context", {}) cx = self.ctx.config.data.setdefault("context", {})
cx["auto_compact"] = True cx["auto_compact"] = True
+3 -3
View File
@@ -290,9 +290,9 @@ class AiEditPipeline:
self.pending = None self.pending = None
self._owner.show_confirm_row(False) self._owner.show_confirm_row(False)
if p.get("image_gens"): if p.get("image_gens"):
from PySide6.QtWidgets import QMessageBox from ...ui.dialog_buttons import confirm
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"), if not confirm(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes: tr("folder.ai_image_confirm_gen")):
self._owner.status_message.emit(tr("folder.ai_image_declined")) self._owner.status_message.emit(tr("folder.ai_image_declined"))
return return
self._generate_then_finalize(p) self._generate_then_finalize(p)
+44 -5
View File
@@ -21,9 +21,9 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, QComboBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
) )
from PySide6.QtCore import Signal from PySide6.QtCore import Qt, Signal
from cowork_local.i18n import on_language_changed, tr from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
@@ -32,6 +32,45 @@ from cowork_local.theme import current_palette
from cowork_local.ui.chat_view import ChatView from cowork_local.ui.chat_view import ChatView
class _AutoExpandInput(QPlainTextEdit):
"""Instruction box: grows with content (1..~6 lines, then scrolls), Enter
submits, Shift+Enter inserts a newline — same convention as the Cowork
composer (``presentation/chat/chat_input_box.py::_Input``), minus its
``/skill``/``/agent`` popups and drag-drop attachment handling, which
don't apply to a single AI-edit instruction. DF-008: a fixed-height
single-line ``QLineEdit`` read as cramped for a full instruction; this
replaces it instead of just nudging the height up further."""
submit = Signal()
MIN_HEIGHT = 36 # matches the old QLineEdit's bumped-up height
MAX_HEIGHT = 140 # ~6 lines, then it scrolls instead of growing further
def __init__(self, parent=None):
super().__init__(parent)
self.setTabChangesFocus(True) # Tab moves focus, doesn't insert a tab
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.textChanged.connect(self._adjust_height)
self._adjust_height()
def _adjust_height(self) -> None:
# QPlainTextEdit reports the document height in LINES, not pixels —
# convert via line spacing (same approach as chat_input_box.py).
lines = self.document().size().height() or 1
line_px = self.fontMetrics().lineSpacing()
h = int(lines * line_px + 2 * self.frameWidth() + 12)
h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
if h != self.height():
self.setFixedHeight(h)
def keyPressEvent(self, e) -> None: # noqa: N802
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
self.submit.emit()
return
super().keyPressEvent(e)
class AiFileEditorDialog(QWidget): class AiFileEditorDialog(QWidget):
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's """Collapsible panel: a Cowork-style inline chat timeline, this panel's
OWN model picker + routing toggle, an instruction box, and an Apply/ OWN model picker + routing toggle, an instruction box, and an Apply/
@@ -94,9 +133,9 @@ class AiFileEditorDialog(QWidget):
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch) ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
row = QHBoxLayout() row = QHBoxLayout()
self.ai_input = QLineEdit() self.ai_input = _AutoExpandInput()
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
self.ai_input.returnPressed.connect(self._ai_send) self.ai_input.submit.connect(self._ai_send)
row.addWidget(self.ai_input, 1) row.addWidget(self.ai_input, 1)
self.ai_send_btn = QPushButton(tr("folder.ai_send")) self.ai_send_btn = QPushButton(tr("folder.ai_send"))
self.ai_send_btn.setObjectName("primary") self.ai_send_btn.setObjectName("primary")
@@ -170,7 +209,7 @@ class AiFileEditorDialog(QWidget):
if not self.preview.root: if not self.preview.root:
self.ai_chat.add_error(tr("folder.ai_no_file")) self.ai_chat.add_error(tr("folder.ai_no_file"))
return return
instruction = self.ai_input.text().strip() instruction = self.ai_input.toPlainText().strip()
if not instruction: if not instruction:
return return
self.ai_input.clear() self.ai_input.clear()
@@ -110,7 +110,10 @@ class OfficeDocumentRenderer:
if self._engine is None: if self._engine is None:
try: try:
from PySide6.QtWebEngineWidgets import QWebEngineView from PySide6.QtWebEngineWidgets import QWebEngineView
from .offline_web_page import install_offline_page
self._engine = QWebEngineView() self._engine = QWebEngineView()
install_offline_page(self._engine)
self._owner.stack.addWidget(self._engine) self._owner.stack.addWidget(self._engine)
except Exception: # noqa: BLE001 except Exception: # noqa: BLE001
self._engine = None self._engine = None
@@ -276,10 +279,9 @@ class OfficeDocumentRenderer:
from cowork_local.core import pptx_edit from cowork_local.core import pptx_edit
o = self._owner o = self._owner
if not skip_confirm and pptx_edit.image_change_requested(content): if not skip_confirm and pptx_edit.image_change_requested(content):
from PySide6.QtWidgets import QMessageBox from cowork_local.ui.dialog_buttons import confirm
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"), if not confirm(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm")) tr("folder.ai_image_confirm")):
if ok != QMessageBox.Yes:
o.status_message.emit(tr("folder.ai_image_declined")) o.status_message.emit(tr("folder.ai_image_declined"))
return False return False
pptx_edit.apply_text_to_pptx(o.current_file, content) pptx_edit.apply_text_to_pptx(o.current_file, content)
+66
View File
@@ -0,0 +1,66 @@
"""HTML preview page that loads nothing from the web while "Block network" is on.
``QWebEngineView.setHtml`` happily fetches every ``<img src="https://...">``,
``<script src>`` and stylesheet the previewed file references — an outbound
connection the user never asked for, made by the app itself. The preview gets
its own off-the-record profile (so the interceptor below touches no other web
view, e.g. the GraphRAG renderer) and every remote request is refused while
the switch is on. Local files, ``data:`` and ``qrc:`` URLs still load.
Lifetime rule: a ``QWebEngineProfile`` must outlive every page that uses it.
A profile owned by the view is destroyed *before* the page (children die in
creation order) — Qt then warns "Release of profile requested but
WebEnginePage still not deleted" and the app can abort later (0xc0000409 in
Qt6Core.dll, seen when switching tabs). So there is ONE profile for the whole
app, owned by the QApplication, and each page is owned by its view.
"""
from __future__ import annotations
from typing import Optional
from PySide6.QtWebEngineCore import (
QWebEnginePage, QWebEngineProfile, QWebEngineSettings, QWebEngineUrlRequestInterceptor,
)
from PySide6.QtWidgets import QApplication
from cowork_local.application.network import network_guard
_REMOTE_SCHEMES = frozenset({"http", "https", "ws", "wss", "ftp"})
_profile: Optional[QWebEngineProfile] = None
_blocker: Optional["RemoteRequestBlocker"] = None # Python must hold it, or it is collected
class RemoteRequestBlocker(QWebEngineUrlRequestInterceptor):
"""Refuses remote URLs whenever the network guard says the network is blocked."""
def interceptRequest(self, info) -> None: # noqa: N802 - Qt override
"""Called by WebEngine for every request the page makes."""
if info.requestUrl().scheme().lower() in _REMOTE_SCHEMES and network_guard.is_blocked():
info.block(True)
def preview_profile() -> QWebEngineProfile:
"""The app-wide off-the-record profile shared by every HTML preview."""
global _profile, _blocker
if _profile is None:
_profile = QWebEngineProfile(QApplication.instance()) # no storage name = off the record
_blocker = RemoteRequestBlocker(_profile)
_profile.setUrlRequestInterceptor(_blocker)
_profile.settings().setAttribute(
QWebEngineSettings.WebAttribute.LocalContentCanAccessRemoteUrls, True)
return _profile
def install_offline_page(view) -> None:
"""Give ``view`` a page on the shared preview profile.
The previewed file is loaded with a ``file://`` base URL, and Qt refuses
every remote resource of such a page unless
``LocalContentCanAccessRemoteUrls`` is on — so web images never showed,
even with the network open. The switch is turned on for the profile; the
interceptor is what keeps remote content out while "Block network" is on.
"""
view.setPage(QWebEnginePage(preview_profile(), view))
__all__ = ["RemoteRequestBlocker", "install_offline_page", "preview_profile"]
+1 -1
View File
@@ -237,7 +237,7 @@ class GraphRenderer(QWidget):
# ---- prewarm / scan lifecycle -------------------------------------------------- # # ---- prewarm / scan lifecycle -------------------------------------------------- #
def prewarm(self) -> None: def prewarm(self) -> None:
"""Pay for the graph view before it is clicked on, not during.""" """Pay for the graph view before it is clicked on, not during."""
if not HAS_WEB_ENGINE or self.web is not None: if self.web is not None:
return return
self._ensure_web() self._ensure_web()
if self._graph is None and self.path_edit.text().strip(): if self._graph is None and self.path_edit.text().strip():
+29 -6
View File
@@ -10,6 +10,7 @@ the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
""" """
from __future__ import annotations from __future__ import annotations
from datetime import date, timedelta
from typing import List from typing import List
from PySide6.QtCore import QTimer, Signal from PySide6.QtCore import QTimer, Signal
@@ -18,6 +19,7 @@ from PySide6.QtWidgets import QHBoxLayout, QLabel, QTabWidget, QVBoxLayout, QWid
from ...core import audit_log from ...core import audit_log
from ...i18n import on_language_changed, tr from ...i18n import on_language_changed, tr
from ...state import AppContext from ...state import AppContext
from ...performance import span
from .tabs.action_logs_tab import ActionLogsTab from .tabs.action_logs_tab import ActionLogsTab
from .tabs.agent_status_tab import AgentStatusTab from .tabs.agent_status_tab import AgentStatusTab
from .tabs.mcp_tab import McpTab from .tabs.mcp_tab import McpTab
@@ -25,11 +27,25 @@ from .tabs.overview_tab import OverviewTab
from .tabs.security_events_tab import SecurityEventsTab from .tabs.security_events_tab import SecurityEventsTab
_REFRESH_MS = 3000 _REFRESH_MS = 3000
# Comfortably larger than any realistic audit-log size — the event tables # Comfortably larger than any realistic audit-log size for the WINDOW of
# have never had pagination controls, so every tab still shows "all matching # events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this
# events" exactly like before; MonitoringQueryService's pagination support # is MonitoringQueryService's query-side page size, kept unbounded so it
# is exercised for real here, just not surfaced as UI (yet). # always returns every matching event within the window; the user-facing
# "Số dòng/trang" control (DF-006 — see shared/event_table.py::set_page_size,
# shared/filter_scaffold.py::build_filter_scaffold's with_page_size) trims
# that down for DISPLAY, client-side, per event tab.
_UNBOUNDED_PAGE_SIZE = 100_000 _UNBOUNDED_PAGE_SIZE = 100_000
# _load_events() re-reads the audit log from disk every _REFRESH_MS (3s) via
# _auto_refresh(), and audit_log.load_events()/load_shared_audit_events() are
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
# gets re-read and re-parsed on EVERY tick, which is what actually made
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
# rendering (EventTable paginates client-side, 5-100 rows/page — see
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
# read.
# 30 days is a live-monitoring window, not a hard retention limit — nothing
# is deleted, older days are simply not re-read on every 3s tick.
_LOG_WINDOW_DAYS = 30
class MonitoringTab(QWidget): class MonitoringTab(QWidget):
@@ -259,14 +275,21 @@ class MonitoringTab(QWidget):
Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội
nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này. nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này.
Chỉ đọc ``_LOG_WINDOW_DAYS`` ngày gần nhất — cả hai nguồn đều lưu theo
file JSONL từng ngày, nên bounding ở đây tránh việc đọc lại TOÀN BỘ
lịch sử mỗi 3 giây (xem ``_auto_refresh``), là nguyên nhân thật của
DF-006 (gây nặng khi log lớn).
""" """
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
shared_dir = self.ctx.config.shared_dir shared_dir = self.ctx.config.shared_dir
with span("monitoring.load_events", window_days=_LOG_WINDOW_DAYS):
if shared_dir: if shared_dir:
from ...core import telemetry_shared from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir) shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events: if shared_events:
return shared_events return shared_events
return audit_log.load_events() return audit_log.load_events(start=start)
def _apply_events_to_event_tabs(self, events: List[dict]) -> None: def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one """Filters the ALREADY-LOADED event list (see ``_load_events`` — one
+63 -4
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
@@ -17,7 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time from .formatters import agent_avatar_icon, fmt_event_time
_MAX_ROWS = 300 _DEFAULT_PAGE_SIZE = 20
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
class _TimeItem(QTableWidgetItem): class _TimeItem(QTableWidgetItem):
@@ -62,6 +63,12 @@ class EventTable(QTableWidget):
"secret_in_output": "warning", "secret_in_output": "warning",
} }
# Emitted whenever the rendered page changes (new data, page-size change,
# or prev/next navigation) — args are (current_page, page_count), both
# 1-based-friendly in that current_page is 0-indexed but page_count is a
# plain count. filter_scaffold.py's pager label/buttons listen to this.
page_changed = Signal(int, int)
def __init__(self, show_result: bool = True): def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS). # Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát. """Bảng sự kiện dùng chung của các tab Giám sát.
@@ -70,6 +77,10 @@ class EventTable(QTableWidget):
là thất bại nên cột ấy chỉ tốn chỗ. là thất bại nên cột ấy chỉ tốn chỗ.
""" """
self._show_result = show_result self._show_result = show_result
self._page_size = _DEFAULT_PAGE_SIZE
self._current_page = 0
self._last_events: List[dict] = []
self._sorted_events: List[dict] = []
super().__init__(0, 7 if show_result else 6) super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers) self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows) self.setSelectionBehavior(QTableWidget.SelectRows)
@@ -98,13 +109,60 @@ class EventTable(QTableWidget):
cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")] cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
self.setHorizontalHeaderLabels(cols) self.setHorizontalHeaderLabels(cols)
def page_size(self) -> int:
"""Số dòng đang hiển thị mỗi trang."""
return self._page_size
def page_count(self) -> int:
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
if not self._sorted_events:
return 1
return -(-len(self._sorted_events) // self._page_size) # ceil div
def current_page(self) -> int:
"""Trang đang hiển thị, đánh số từ 0."""
return self._current_page
def go_to_page(self, page: int) -> None:
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
self._current_page = page
self._render_current_page()
def next_page(self) -> None:
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
self.go_to_page(self._current_page + 1)
def prev_page(self) -> None:
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
self.go_to_page(self._current_page - 1)
def set_page_size(self, n: int) -> None:
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
self._page_size = n
self._current_page = 0
self._render_current_page()
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``_MAX_ROWS``. """Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
trước (DF-006)."""
self._last_events = events
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
self._current_page = 0
self._render_current_page()
def _render_current_page(self) -> None:
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
từ ``self._sorted_events`` đã sắp sẵn.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai. nạp chậm đi theo bậc hai.
""" """
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS] self._current_page = max(0, min(self._current_page, self.page_count() - 1))
start = self._current_page * self._page_size
events = self._sorted_events[start:start + self._page_size]
self.setSortingEnabled(False) self.setSortingEnabled(False)
self.setRowCount(len(events)) self.setRowCount(len(events))
for row, ev in enumerate(events): for row, ev in enumerate(events):
@@ -149,6 +207,7 @@ class EventTable(QTableWidget):
self.setItem(row, col, item) self.setItem(row, col, item)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", "")) self.apply_filter(getattr(self, "_filter_needle", ""))
self.page_changed.emit(self._current_page, self.page_count())
def apply_filter(self, needle: str) -> None: def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường).""" """Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
@@ -16,13 +16,13 @@ from typing import Callable, Dict, Optional
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QApplication, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QTableWidget, QVBoxLayout, QWidget, QSplitter, QTableWidget, QVBoxLayout, QWidget,
) )
from ....i18n import bind_tip, tr from ....i18n import bind_tip, tr
from ....ui.icons import icon from ....ui.icons import icon
from .event_table import ClickOutsideCloser, EventTable from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
from .event_detail_panel import EventDetailPanel from .event_detail_panel import EventDetailPanel
@@ -47,11 +47,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
def build_filter_scaffold( def build_filter_scaffold(
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None], page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
title_key: Optional[str] = None, with_search: bool = True, title_key: Optional[str] = None, with_search: bool = True,
with_detail: bool = False, with_detail: bool = False, with_page_size: bool = False,
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None, on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
) -> Dict[str, object]: ) -> Dict[str, object]:
"""Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm, """Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm,
nút lọc bằng AI và panel chi tiết. nút lọc bằng AI, control "Số dòng/trang" (nếu ``with_page_size``) và panel
chi tiết.
Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ
này được dựng một lần và dùng chung. này được dựng một lần và dùng chung.
@@ -91,6 +92,55 @@ def build_filter_scaffold(
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn)) ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
row.addWidget(search, 1) row.addWidget(search, 1)
row.addWidget(ai_btn) row.addWidget(ai_btn)
if with_page_size and isinstance(table, EventTable):
# DF-006: the item-per-page count was never surfaced anywhere in
# the UI (design called for it) — EventTable already trims to a
# page size internally, this just makes that number visible AND
# user-choosable instead of a fixed constant.
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
page_size_combo = QComboBox()
for n in PAGE_SIZE_OPTIONS:
page_size_combo.addItem(str(n), n)
current = table.page_size()
page_size_combo.setCurrentIndex(
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
page_size_combo.currentIndexChanged.connect(
lambda i: table.set_page_size(page_size_combo.itemData(i)))
row.addWidget(page_size_lbl)
row.addWidget(page_size_combo)
# DF-006 follow-up: trimming to a page size alone silently dropped
# every row past it with no way back to see them — prev/next
# buttons plus a "trang X/Y" indicator make the rest reachable.
page_prev_btn = QPushButton()
page_prev_btn.setIcon(icon("chevron-left"))
page_prev_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_prev_btn, "monitoring.page_prev")
page_next_btn = QPushButton()
page_next_btn.setIcon(icon("chevron-right"))
page_next_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_next_btn, "monitoring.page_next")
page_indicator_lbl = QLabel()
def _refresh_pager(cur: int = None, total: int = None) -> None:
if cur is None or total is None:
cur, total = table.current_page(), table.page_count()
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
page_prev_btn.setEnabled(cur > 0)
page_next_btn.setEnabled(cur < total - 1)
page_prev_btn.clicked.connect(table.prev_page)
page_next_btn.clicked.connect(table.next_page)
table.page_changed.connect(_refresh_pager)
_refresh_pager()
row.addWidget(page_prev_btn)
row.addWidget(page_indicator_lbl)
row.addWidget(page_next_btn)
parts.update(
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
lay.addLayout(row) lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn) parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -25,13 +25,16 @@ class ActionLogsTab(QWidget):
parts = build_filter_scaffold( parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all, self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.action_logs_title", title_key="monitoring.action_logs_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) with_search=True, with_detail=True, with_page_size=True,
on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"] self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"] self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"] self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"] self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +47,8 @@ class ActionLogsTab(QWidget):
self.detail_panel.retranslate() self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -20,6 +20,7 @@ from ....core import admin_agents, preview_ai
from ....core.worker import AgentWorker from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr from ....i18n import on_language_changed, tr
from ....state import AppContext from ....state import AppContext
from ....ui.dialog_buttons import dialog_buttons
from ....ui.icons import icon from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget from ....ui.widgets import ToggleSwitch, badge_pill_widget
@@ -95,7 +96,7 @@ class AgentEditDialog(QDialog):
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled")) self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
self.enabled_chk.setChecked(agent.enabled if agent else True) self.enabled_chk.setChecked(agent.enabled if agent else True)
form.addRow("", self.enabled_chk) form.addRow("", self.enabled_chk)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
form.addRow(buttons) form.addRow(buttons)
@@ -26,7 +26,7 @@ from typing import Dict, List
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
) )
@@ -35,6 +35,7 @@ from ....core import admin_agents
from ....core.worker import AgentWorker from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr from ....i18n import on_language_changed, tr
from ....state import AppContext from ....state import AppContext
from ....ui.dialog_buttons import confirm
from ....ui.icons import icon from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog from .agent_edit_dialog import AgentEditDialog
@@ -189,9 +190,8 @@ class AgentsAdminTab(QWidget):
agent = admin_agents.load_agent(agent_id, self._dir()) agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None: if agent is None:
return return
if QMessageBox.question( if not confirm(self, tr("agents_admin.delete_title"),
self, tr("agents_admin.delete_title"), tr("agents_admin.delete_confirm", name=agent.name)):
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
return return
admin_agents.delete_agent(agent.agent_id, self._dir()) admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh() self.refresh()
+6 -1
View File
@@ -25,13 +25,16 @@ class McpTab(QWidget):
parts = build_filter_scaffold( parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all, self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.mcp_history_title", title_key="monitoring.mcp_history_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) with_search=True, with_detail=True, with_page_size=True,
on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"] self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"] self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"] self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"] self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +47,8 @@ class McpTab(QWidget):
self.detail_panel.retranslate() self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -139,10 +139,10 @@ class PricingPanel(QGroupBox):
def _add_pricing_row(self) -> None: def _add_pricing_row(self) -> None:
"""Thêm một dòng đơn giá trống để người dùng điền tay.""" """Thêm một dòng đơn giá trống để người dùng điền tay."""
from PySide6.QtWidgets import QInputDialog from ....ui.dialog_buttons import ask_text
from ....core import model_pricing as mp from ....core import model_pricing as mp
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"), name, ok = ask_text(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt")) tr("monitoring.pricing_add_prompt"))
name = (name or "").strip() name = (name or "").strip()
if not ok or not name: if not ok or not name:
@@ -31,13 +31,16 @@ class SecurityEventsTab(QWidget):
parts = build_filter_scaffold( parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all, self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.security_events_title", title_key="monitoring.security_events_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) with_search=True, with_detail=True, with_page_size=True,
on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"] self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"] self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"] self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"] self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -50,6 +53,8 @@ class SecurityEventsTab(QWidget):
self.detail_panel.retranslate() self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -179,9 +179,12 @@ class ToolsAdminTab(QWidget):
hdr.addWidget(sw) hdr.addWidget(sw)
lay.addLayout(hdr) lay.addLayout(hdr)
desc = QLabel(spec.description) # spec.description là mô tả gửi cho mô hình (schema function-calling),
# luôn tiếng Anh và viết cho máy đọc — thẻ này dùng bản dịch riêng.
desc_text = tr(f"tools_admin.desc.{spec.name}")
desc = QLabel(desc_text)
desc.setWordWrap(True) desc.setWordWrap(True)
desc.setToolTip(spec.description) desc.setToolTip(desc_text)
desc.setObjectName("hint") desc.setObjectName("hint")
desc.setStyleSheet("border: none;") desc.setStyleSheet("border: none;")
lay.addWidget(desc) lay.addWidget(desc)
@@ -213,7 +216,8 @@ class ToolsAdminTab(QWidget):
result. Respects the fetch_url toggle: when web access is OFF the agent result. Respects the fetch_url toggle: when web access is OFF the agent
cannot reach the internet, so the test reports that instead of probing.""" cannot reach the internet, so the test reports that instead of probing."""
disabled = ("fetch_url" in self.ctx.config.tools_disabled disabled = ("fetch_url" in self.ctx.config.tools_disabled
or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))) or not bool(self.ctx.config.agent_security.get("allow_url_fetch", True))
or bool(self.ctx.config.agent_security.get("block_network", False)))
if disabled: if disabled:
self.test_internet_status.setText(tr("tools_admin.internet_disabled")) self.test_internet_status.setText(tr("tools_admin.internet_disabled"))
self.test_internet_status.setStyleSheet("color: #c00;") self.test_internet_status.setStyleSheet("color: #c00;")
@@ -32,6 +32,7 @@ from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr from cowork_local.i18n import tr
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
from cowork_local.state import AppContext from cowork_local.state import AppContext
from cowork_local.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.icons import icon from cowork_local.ui.icons import icon
@@ -79,8 +80,8 @@ class AiTaskCreatorDialog(QDialog):
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed) self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import")) self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) ok="schedtask.ai_confirm")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
self.buttons.accepted.connect(self._confirm) self.buttons.accepted.connect(self._confirm)
self.buttons.rejected.connect(self.reject) self.buttons.rejected.connect(self.reject)
@@ -38,6 +38,7 @@ from cowork_local.infrastructure.persistence.json.task_repository_impl import (
) )
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
from cowork_local.theme import current_palette from cowork_local.theme import current_palette
from cowork_local.ui.dialog_buttons import confirm
from cowork_local.ui.osutil import open_path from cowork_local.ui.osutil import open_path
# Priority shown as a plain text tag (no colored-emoji squares). Only the # Priority shown as a plain text tag (no colored-emoji squares). Only the
@@ -317,9 +318,8 @@ class KanbanBoardWidget(QWidget):
elif chosen == next_act: elif chosen == next_act:
self._create_next_from_output(task) self._create_next_from_output(task)
elif chosen == del_act: elif chosen == del_act:
if QMessageBox.question(self, tr("schedtask.menu_delete"), if confirm(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", "")) tr("schedtask.delete_confirm", title=task.get("title", ""))):
) == QMessageBox.Yes:
self._service.delete(tid) self._service.delete(tid)
self.refresh() self.refresh()
@@ -335,9 +335,8 @@ class KanbanBoardWidget(QWidget):
"""Confirm, then delete every task in ``selected``. Split out of """Confirm, then delete every task in ``selected``. Split out of
_bulk_delete_menu so tests can drive it directly without having to _bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup.""" fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question( if not confirm(self, tr("schedtask.menu_delete"),
self, tr("schedtask.menu_delete"), tr("schedtask.delete_multi_confirm", n=len(selected))):
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
return False return False
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)] ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
self._service.bulk_delete(ids) self._service.bulk_delete(ids)
@@ -381,7 +380,7 @@ class KanbanBoardWidget(QWidget):
nxt["dependency"]["previous_task_id"] = task["task_id"] nxt["dependency"]["previous_task_id"] = task["task_id"]
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"]) err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
if err: if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err) QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return return
self._repo.save(nxt) self._repo.save(nxt)
task["dependency"]["next_task_id"] = nxt["task_id"] task["dependency"]["next_task_id"] = nxt["task_id"]
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
) )
from cowork_local.i18n import tr from cowork_local.i18n import tr
from cowork_local.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.osutil import open_path from cowork_local.ui.osutil import open_path
@@ -53,7 +54,7 @@ class RunHistoryDialog(QDialog):
self.table.itemDoubleClicked.connect(self._open_artifact) self.table.itemDoubleClicked.connect(self._open_artifact)
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Close) buttons = dialog_buttons(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
root.addWidget(buttons) root.addWidget(buttons)
+4 -4
View File
@@ -43,10 +43,10 @@ class AboutSettingsWidget(QWidget):
self.app_label.setFont(font) self.app_label.setFont(font)
layout.addWidget(self.app_label) layout.addWidget(self.app_label)
self.credit_label = QLabel(tr("app.credit")) self.version_label = QLabel(tr("app.version", v=__version__))
self.credit_label.setObjectName("faint") self.version_label.setObjectName("faint")
self.credit_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.version_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(self.credit_label) layout.addWidget(self.version_label)
layout.addStretch(1) layout.addStretch(1)
+27
View File
@@ -0,0 +1,27 @@
"""Width of a navigation list whose current row is drawn bold.
The theme draws the selected ``sectionIndex`` row with ``font-weight: 600``
and 10px padding each side (``theme/qss.py``). Sizing the list from the plain
font left the selected label too wide for its row, so "Sandbox Security
Layer" was cut off in Settings as soon as it was picked.
"""
from __future__ import annotations
from typing import Iterable
from PySide6.QtGui import QFont, QFontMetrics
#: Item padding (10px x 2) + item radius + list frame, with a little slack.
ROW_CHROME_PX = 48
def selected_label_width(widget, labels: Iterable[str]) -> int:
"""Pixels needed to show the widest of ``labels`` in the bold selected style."""
widget.ensurePolished()
bold = QFont(widget.font())
bold.setWeight(QFont.Weight.DemiBold)
metrics = QFontMetrics(bold)
return max((metrics.horizontalAdvance(label) for label in labels), default=0) + ROW_CHROME_PX
__all__ = ["ROW_CHROME_PX", "selected_label_width"]
@@ -27,7 +27,14 @@ def _frozen_onefile() -> bool:
return True return True
# QtWebEngine is noisy and unreliable on the macOS runtime we support (GPU/
# helper-process failures leave the stacked view blank). The native Qt graph is
# already available and avoids that failure path entirely.
HAS_WEB_ENGINE = False
try: # WebEngine + WebChannel are optional PySide6 add-ons try: # WebEngine + WebChannel are optional PySide6 add-ons
if sys.platform == "darwin":
raise ImportError("use native graph renderer on macOS")
from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401 from PySide6.QtWebEngineWidgets import QWebEngineView # noqa: F401
from PySide6.QtWebChannel import QWebChannel # noqa: F401 from PySide6.QtWebChannel import QWebChannel # noqa: F401
HAS_WEB_ENGINE = not _frozen_onefile() HAS_WEB_ENGINE = not _frozen_onefile()
+11 -2
View File
@@ -42,5 +42,14 @@ def build_config(path: Path | None = None) -> JsonConfigRepository:
def build_context(path: Path | None = None) -> AppContext: def build_context(path: Path | None = None) -> AppContext:
"""Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker.""" """Dựng AppContext hoàn chỉnh — điểm vào cho ``app.run()`` và cho checker.
return AppContext(build_config(path))
Nối luôn cổng mạng chung vào cấu hình SỐNG của context: đổi công tắc
"Chặn mạng" trong Settings là có hiệu lực ngay ở lời gọi mạng kế tiếp.
"""
from ...application.network import network_guard
from ...core.agent_security import sandbox_settings
ctx = AppContext(build_config(path))
network_guard.bind(lambda: sandbox_settings(ctx.config)[1])
return ctx
+11 -9
View File
@@ -32,9 +32,7 @@ from .rail_metrics import _NAV_MIN_WIDTH
from .tray_manager import TrayManager from .tray_manager import TrayManager
from ...state import AppContext from ...state import AppContext
from ...core.task_scheduler import TaskScheduler from ...core.task_scheduler import TaskScheduler
from ...ui.cowork_tab import CoworkTab
from ...ui.sidebar import HistorySidebar from ...ui.sidebar import HistorySidebar
from ..graph.structure_graph_view import StructureGraphView
from ...ui.workspace_tab import WorkspaceTab from ...ui.workspace_tab import WorkspaceTab
@@ -111,10 +109,9 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
# Workspace screen (per selected project). GraphRAG's heavy # Workspace screen (per selected project). GraphRAG's heavy
# QtWebEngine is still built lazily on first display # QtWebEngine is still built lazily on first display
# (StructureGraphView._ensure_web). # (StructureGraphView._ensure_web).
from ...ui.cowork_tab import CoworkTab
self.cowork = CoworkTab(ctx) self.cowork = CoworkTab(ctx)
self.structure = StructureGraphView(ctx) self.structure = None
self.structure.status_message.connect(self.statusBar().showMessage)
self.cowork.output_changed.connect(self.structure.schedule_rescan)
self.cowork.status_message.connect(self.statusBar().showMessage) self.cowork.status_message.connect(self.statusBar().showMessage)
# Refresh History (list + running markers + current highlight) whenever a # Refresh History (list + running markers + current highlight) whenever a
# conversation is created/updated or a turn finishes. # conversation is created/updated or a turn finishes.
@@ -177,10 +174,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self.help_agent.status_message.connect(self.statusBar().showMessage) self.help_agent.status_message.connect(self.statusBar().showMessage)
self.statusBar().showMessage(tr("app.status.ready")) self.statusBar().showMessage(tr("app.status.ready"))
# Dòng ghi công tác giả đã chuyển vào Cài đặt ▸ Giới thiệu # Góc dưới phải: đúng một dòng phiên bản (cùng nguồn với tiêu đề cửa sổ).
# (presentation/settings/about_widget.py). Nó từng là widget thường trực # Tắt size grip — nó vẽ một vệt ngay bên phải chữ; cửa sổ vẫn kéo được cạnh.
# ở góc dưới phải: chiếm một góc màn hình trên MỌI màn hình, suốt cả self.statusBar().setSizeGripEnabled(False)
# phiên, cho một thông tin chỉ cần đọc một lần. self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
self._restore_sessions() self._restore_sessions()
# Open on "All projects…" — literally the same call the nav rail's link # Open on "All projects…" — literally the same call the nav rail's link
# of that name makes, so the rail highlight and the content can never # of that name makes, so the rail highlight and the content can never
@@ -218,6 +215,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
# Keep the floating Help assistant pinned to the bottom-right corner. # Keep the floating Help assistant pinned to the bottom-right corner.
if getattr(self, "help_agent", None) is not None: if getattr(self, "help_agent", None) is not None:
# The Cowork composer can wrap an extra control row as the window
# narrows/widens, which changes how much bottom guard the dock
# needs — recompute it on every resize, not just reposition with
# whatever guard height was last measured at tab-entry time.
self._update_dock_guard()
self.help_agent.reposition() self.help_agent.reposition()
def showEvent(self, event): # noqa: N802 - Qt override def showEvent(self, event): # noqa: N802 - Qt override
+16 -25
View File
@@ -81,7 +81,7 @@ class NavRailMixin:
# 16px icon up with the nav items' icons below (1px list frame + item # 16px icon up with the nav items' icons below (1px list frame + item
# padding) — same indent level, same icon size as e.g. Dashboard. # padding) — same indent level, same icon size as e.g. Dashboard.
toggle_row = QHBoxLayout() toggle_row = QHBoxLayout()
toggle_row.setContentsMargins(0, 8, 10, 8) toggle_row.setContentsMargins(10, 8, 10, 8)
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
toggle_row.addStretch(1) toggle_row.addStretch(1)
nvl.addLayout(toggle_row) nvl.addLayout(toggle_row)
@@ -111,7 +111,7 @@ class NavRailMixin:
self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu)
self.nav_project_btn.setVisible(False) self.nav_project_btn.setVisible(False)
head = QVBoxLayout() head = QVBoxLayout()
head.setContentsMargins(6, 0, 6, 6) head.setContentsMargins(10, 0, 10, 6)
head.setSpacing(6) head.setSpacing(6)
head.addWidget(self.nav_project) head.addWidget(self.nav_project)
head.addWidget(self.nav_project_btn) head.addWidget(self.nav_project_btn)
@@ -136,7 +136,7 @@ class NavRailMixin:
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_body = QWidget() scroll_body = QWidget()
sv = QVBoxLayout(scroll_body) sv = QVBoxLayout(scroll_body)
sv.setContentsMargins(0, 0, 0, 0) sv.setContentsMargins(6, 0, 6, 0)
sv.setSpacing(0) sv.setSpacing(0)
sv.addWidget(self.nav, 0) sv.addWidget(self.nav, 0)
# RECENTS — the threads of the project named in the picker above, right # RECENTS — the threads of the project named in the picker above, right
@@ -197,9 +197,9 @@ class NavRailMixin:
def _nav_rows(self): def _nav_rows(self):
"""(tree, page, sub, label, icon, enabled) for every row, rail order. """(tree, page, sub, label, icon, enabled) for every row, rail order.
Workspace contributes all five of its sub-views — including the two the Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ
project gate currently disables — so the rail never changes shape while những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện
the user is looking at it. chúng ở dạng mờ.
""" """
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
for label, sub, ic, on in self.workspace.nav_entries()] for label, sub, ic, on in self.workspace.nav_entries()]
@@ -238,37 +238,28 @@ class NavRailMixin:
tree.clear() tree.clear()
tree.blockSignals(blocked) tree.blockSignals(blocked)
for tree, page, sub, label, icon_name, enabled in spec: for tree, page, sub, label, icon_name, enabled in spec:
if not enabled:
# Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa.
continue
it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
it.setIcon(0, _icon(icon_name)) it.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
if not enabled: if self._nav_collapsed:
# Same gate as before, shown instead of hidden: the row stays
# in place, greyed, and says why it cannot be opened.
it.setDisabled(True)
it.setToolTip(0, tr("app.nav.needs_project"))
elif self._nav_collapsed:
it.setToolTip(0, label) it.setToolTip(0, label)
blocked = tree.blockSignals(True) blocked = tree.blockSignals(True)
tree.addTopLevelItem(it) tree.addTopLevelItem(it)
tree.blockSignals(blocked) tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the # Rows plus frame, nothing else: the flat ``+ 8`` this replaces
# stretch in between belongs to RECENTS. # left 6px of dead space under the last row, and since Settings
# # sits directly under nav_bottom it fell between Giám sát and
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px # Settings alone — 18/26px for rows that read as one list.
# of dead space under the last row of each list, and because the
# Settings button sits DIRECTLY under nav_bottom (nvl has no
# spacing), that space landed between Giám sát and Settings only —
# so three rows that read as one list were spaced 18/26px. Padding
# a row is the item delegate's job; this is the frame's.
row_h = 0 row_h = 0
for tree in (self.nav, self.nav_bottom): for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount() n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else row_h row_h = tree.sizeHintForRow(0) if n else row_h
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth()) tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
# Settings is one more row of the same list, so it gets the rows' # Settings is one more row of the list, so it takes the rows' own
# own height rather than a second set of paddings guessed to match # height instead of paddings guessed to match it.
# it — the only way the three stay evenly spaced when the font (and
# with it ``sizeHintForRow``) is not the one this was tuned on.
if row_h and hasattr(self, "_nav_settings_btn"): if row_h and hasattr(self, "_nav_settings_btn"):
self._nav_settings_btn.setFixedHeight(row_h) self._nav_settings_btn.setFixedHeight(row_h)
if keep: if keep:
+3 -2
View File
@@ -142,10 +142,11 @@ class PageRegistryMixin:
Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ Vệt sáng trên thanh menu cũng cập nhật ở đây, để nó đi theo NỘI DUNG chứ
không theo thứ vừa được bấm. không theo thứ vừa được bấm.
""" """
was_page = self.pages.currentIndex()
self._ensure_page(page) # build lazy page on first visit self._ensure_page(page) # build lazy page on first visit
self.pages.setCurrentIndex(page) self.pages.setCurrentIndex(page)
if page == self._ROW_WORKSPACE: if page == self._ROW_WORKSPACE and was_page != page:
self.workspace.refresh() # re-list projects + threads on entry self.workspace.refresh() # refresh only when entering Workspace
widget = self._page_widgets[page] widget = self._page_widgets[page]
if sub is not None and hasattr(widget, "select_subtab"): if sub is not None and hasattr(widget, "select_subtab"):
# Enforce the project gate here rather than at each entry point. A # Enforce the project gate here rather than at each entry point. A
+7 -1
View File
@@ -13,8 +13,14 @@ from PySide6.QtWidgets import QStyledItemDelegate
# ---- kích thước --------------------------------------------------------- # ---- kích thước ---------------------------------------------------------
_NAV_EXPANDED_WIDTH = 232 _NAV_EXPANDED_WIDTH = 232
_NAV_COLLAPSED_WIDTH = 54 _NAV_COLLAPSED_WIDTH = 54
_NAV_ROW_INSET = 4 _NAV_ROW_INSET = 8
_NAV_ROW_GAP = 6 _NAV_ROW_GAP = 6
# Khe TRÊN nút Cài đặt, tính bằng khoảng trống thật trong layout của rail.
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
# Settings dùng cùng nhịp hàng với Dashboard và Monitoring.
_NAV_SETTINGS_GAP = 0
# 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du # 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh. # rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232 _NAV_MIN_WIDTH = 232
+6
View File
@@ -42,6 +42,12 @@ class RailProjectMixin:
# No project yet: say so, and say what to do about it, instead of # No project yet: say so, and say what to do about it, instead of
# leaving an empty box and a button that silently does nothing. # leaving an empty box and a button that silently does nothing.
self.nav_project.addItem(tr("app.nav.no_project"), "") self.nav_project.addItem(tr("app.nav.no_project"), "")
elif not current:
# Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá
# project đang mở). Không có mục này thì combo rơi về mục 0 và
# chỉ bừa vào project đầu danh sách, trong khi cổng
# Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng.
self.nav_project.insertItem(0, tr("app.nav.pick_project"), "")
idx = self.nav_project.findData(current) idx = self.nav_project.findData(current)
if idx >= 0: if idx >= 0:
self.nav_project.setCurrentIndex(idx) self.nav_project.setCurrentIndex(idx)
+9 -1
View File
@@ -42,7 +42,14 @@ class SessionEventsMixin:
self.sidebar.refresh() self.sidebar.refresh()
self._refresh_rail_recents() # the rail shortcut follows the panel self._refresh_rail_recents() # the rail shortcut follows the panel
QTimer.singleShot(0, _do) # Coalesce bursts from turn/tool/history signals into one sidebar read.
timer = getattr(self, "_history_refresh_timer", None)
if timer is None:
timer = QTimer(self)
timer.setSingleShot(True)
timer.timeout.connect(_do)
self._history_refresh_timer = timer
timer.start(0)
def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None: def _on_scheduled_task_done(self, task_id: str, ok: bool) -> None:
"""Desktop notification for a finished scheduled task (toast always, """Desktop notification for a finished scheduled task (toast always,
tray balloon when the window isn't focused), then refresh History — tray balloon when the window isn't focused), then refresh History —
@@ -106,4 +113,5 @@ class SessionEventsMixin:
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục.""" """Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed self.cowork._apply_output_folder_label() # project may have been renamed
if getattr(self, "structure", None) is not None:
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
+18 -6
View File
@@ -32,10 +32,10 @@ class TopBarMixin:
``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình. ``_build_account_row`` ngay bên dưới, chỉ khác chỗ đặt trên màn hình.
""" """
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton, QStyle
from ...i18n import tr from ...i18n import tr
from ...ui.icons import icon as _icon from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET from .rail_metrics import _NAV_ROW_INSET, _NAV_SETTINGS_GAP
# Bottom-pinned group: the places you visit occasionally, kept out of the # Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in # way of the ones you live in. A hairline (styled via #navrailBottom in
@@ -59,11 +59,17 @@ class TopBarMixin:
# that number. Adding it again here made the row taller than the button # that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text # (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát. # 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0) srow.setContentsMargins(_NAV_ROW_INSET + 2, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP) # Khe giữa icon và chữ phải là khe của STYLE, không phải nhịp riêng của
# rail: delegate của cây vẽ chữ ngay sau hộp icon, cách đúng
# ``PM_FocusFrameHMargin + 1``. Đặt ``_NAV_ROW_GAP + 4`` (=10) ở đây cộng
# với 10px lề trái và hộp icon 22px thành 42 — trong khi Dashboard /
# Giám sát đặt chữ ở 35, nên hàng Cài đặt thụt phải 7px.
srow.setSpacing(
self.nav_bottom.style().pixelMetric(QStyle.PM_FocusFrameHMargin) + 1)
self._nav_settings_icon = QLabel() self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) self._nav_settings_icon.setPixmap(_icon("gear").pixmap(16, 16))
self._nav_settings_icon.setFixedSize(16, 16) self._nav_settings_icon.setFixedSize(22, 16)
self._nav_settings_text = QLabel(tr("app.settings")) self._nav_settings_text = QLabel(tr("app.settings"))
srow.addWidget(self._nav_settings_icon) srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text) srow.addWidget(self._nav_settings_text)
@@ -71,6 +77,12 @@ class TopBarMixin:
# The first _rebuild_nav() ran before this button existed (it is what # The first _rebuild_nav() ran before this button existed (it is what
# fills the list this row belongs under), so take the height here too. # fills the list this row belongs under), so take the height here too.
self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0)) self._nav_settings_btn.setFixedHeight(self.nav_bottom.sizeHintForRow(0))
# Khe TRÊN hàng Cài đặt, xin thẳng từ layout — thanh rail đặt
# ``setSpacing(0)`` nên không có khoảng nào sẵn, và margin trong QSS thì
# không mua được pixel nào (xem ``_NAV_SETTINGS_GAP``). Cài đặt là việc
# khác với nhóm Dashboard/Giám sát ngay trên nó; dán sát vào thì hai thứ
# đọc thành một khối.
nvl.addSpacing(_NAV_SETTINGS_GAP)
nvl.addWidget(self._nav_settings_btn) nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row() self._account_row = self._build_account_row()
+28 -11
View File
@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
) )
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import confirm
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``. #: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
_DEFAULT_PROJECT_NAME = "Project" _DEFAULT_PROJECT_NAME = "Project"
@@ -51,7 +52,7 @@ class ProjectRow(QWidget):
lay = QVBoxLayout(self) lay = QVBoxLayout(self)
lay.setContentsMargins(6, 4, 6, 4) lay.setContentsMargins(6, 4, 6, 4)
lay.setSpacing(0) lay.setSpacing(3)
self.title_label = QLabel(name) self.title_label = QLabel(name)
self.counts_label = QLabel() self.counts_label = QLabel()
self.counts_label.setObjectName("hint") self.counts_label.setObjectName("hint")
@@ -89,6 +90,7 @@ def _row_layout_of(widget: QWidget) -> QLayout | None:
return None return None
class ProjectEditingMixin: class ProjectEditingMixin:
"""Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``. """Danh sách project + CRUD + chế độ sửa. Trộn vào ``WorkspaceTab``.
@@ -132,10 +134,17 @@ class ProjectEditingMixin:
# dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong # dung luat ma _new_btn da theo (_new_btn.setVisible(on_project) trong
# WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay. # WorkspaceTab._apply_pane_visibility) — hai nut nay phai theo y nhu vay.
self.tabs.currentChanged.connect(self._sync_project_buttons) self.tabs.currentChanged.connect(self._sync_project_buttons)
# Đổi project cũng phải đồng bộ lại: ``_load_current`` nạp form và đặt
# ``_current_id`` rồi phát tín hiệu này, nhưng không đụng tới ba nút.
self.project_selected.connect(self._sync_project_buttons)
self.project_list.setContextMenuPolicy(Qt.CustomContextMenu) self.project_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.project_list.customContextMenuRequested.connect(self._show_project_menu) self.project_list.customContextMenuRequested.connect(self._show_project_menu)
# Luật "mỗi thư mục một project" sống ở module riêng — xem
# ``project_folder_rules.py`` về lý do nó không nằm trong file này.
self.install_project_folder_rule()
self.set_project_editable(False) self.set_project_editable(False)
# ---- chế độ chỉ-xem / sửa ------------------------------------------- # ---- chế độ chỉ-xem / sửa -------------------------------------------
@@ -152,15 +161,14 @@ class ProjectEditingMixin:
Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``). Bật: ngược lại, và nút Lưu chuyển sang màu xác nhận (token ``success``).
""" """
self._project_editable = on self._project_editable = on
has_project = bool(getattr(self, "_current_id", ""))
for field in self._editable_fields(): for field in self._editable_fields():
# setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy # setReadOnly thay vì setEnabled: ô mờ đi thì không bôi đen copy
# được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem. # được nữa, mà đọc và copy chính là việc của chế độ chỉ-xem.
field.setReadOnly(not on) field.setReadOnly(not on)
self._browse_btn.setEnabled(on and has_project) # Ba nút không tự bật/tắt ở đây: ``_sync_project_buttons`` mới là nơi
self._save_btn.setEnabled(on and has_project) # duy nhất tính trạng thái của chúng, vì nó còn chạy cả khi người dùng
self._edit_btn.setEnabled(not on and has_project) # đổi project — lúc đó ``set_project_editable`` không được gọi.
self._sync_project_buttons() self._sync_project_buttons()
# Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc # Nút Lưu xanh lá khi đang sửa (hành động xác nhận), về màu nhấn mặc
@@ -170,15 +178,25 @@ class ProjectEditingMixin:
self._repolish(self._edit_btn) self._repolish(self._edit_btn)
def _sync_project_buttons(self, *_a) -> None: def _sync_project_buttons(self, *_a) -> None:
"""Ẩn "Sửa project" và "Lưu project" ngoài sub-tab Project. """Đồng bộ CẢ hiện/ẩn LẪN bật/mờ của ba nút theo trạng thái hiện tại.
Chúng nằm trên hàng tiêu đề dùng chung, nên không tự ẩn là chúng hiện Ẩn ngoài sub-tab Project: chúng nằm trên hàng tiêu đề dùng chung, nên
cả ở Cowork — nơi không có biểu mẫu project nào để sửa hay lưu. không tự ẩn là chúng hiện cả ở Cowork — nơi không có biểu mẫu project
nào để sửa hay lưu.
Bật/mờ cũng tính ở đây chứ không ở ``set_project_editable``: đổi
project KHÔNG đi qua hàm đó (``_load_current`` chỉ nạp lại form), nên
để ở đó thì "Sửa project" giữ nguyên trạng thái tính từ lúc dựng —
lúc chưa project nào được chọn — và cứ mờ mãi dù project đã mở.
""" """
on_project = self.tabs.currentIndex() == self._project_tab_idx on_project = self.tabs.currentIndex() == self._project_tab_idx
has_project = bool(getattr(self, "_current_id", "")) has_project = bool(getattr(self, "_current_id", ""))
dang_sua = bool(getattr(self, "_project_editable", False))
self._edit_btn.setVisible(on_project and has_project) self._edit_btn.setVisible(on_project and has_project)
self._save_btn.setVisible(on_project and has_project) self._save_btn.setVisible(on_project and has_project)
self._edit_btn.setEnabled(not dang_sua and has_project)
self._save_btn.setEnabled(dang_sua and has_project)
self._browse_btn.setEnabled(dang_sua and has_project)
@staticmethod @staticmethod
def _repolish(widget: QWidget) -> None: def _repolish(widget: QWidget) -> None:
@@ -292,9 +310,8 @@ class ProjectEditingMixin:
project = load_project(pid) if pid else None project = load_project(pid) if pid else None
if project is None: if project is None:
return return
if QMessageBox.question( if not confirm(self, tr("workspace.delete"),
self, tr("workspace.delete"), tr("workspace.delete_confirm", name=project.name)):
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
return return
delete_project(pid) delete_project(pid)
self._current_id = "" self._current_id = ""
@@ -0,0 +1,113 @@
"""Luật "mỗi thư mục làm việc chỉ thuộc về MỘT project".
Tách khỏi ``project_editing.py`` chứ không nhét thêm vào đó: file kia đã gom
bốn tính năng và thêm luật này là chạm trần 400 dòng của
``scripts/check_loc.py``. Đây cũng là một mối quan tâm riêng — nó không nói về
việc *sửa* một project mà về việc hai project không được giẫm lên nhau.
Luật có hai nửa, cố ý không đối xứng:
* **Chặn lúc CHỌN.** Ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn Project,
thư mục cloud, nút chọn thư mục trong tab Cowork) đều đi qua
:func:`folder_taken_blocked`, để cả ba chặn giống hệt nhau. Không chặn ở
"Lưu project": nút đó chỉ ghi tên/mô tả/chỉ dẫn, chặn ở đó sẽ khoá luôn việc
đổi tên một project lỡ đang trùng thư mục.
* **Cảnh báo cho cái ĐANG sai.** Dữ liệu cũ có thể đã có hai project trỏ vào
cùng một thư mục, mà nửa trên chỉ chặn từ nay trở đi. Nhãn dưới ô "Thư mục
làm việc" nói ra điều đó và để người dùng tự đổi — sửa hộ là tự ý đụng vào
dữ liệu của họ.
Phép so trùng nằm ở ``core/projects.py::folder_conflict`` (thuần, không Qt).
"""
from __future__ import annotations
from PySide6.QtWidgets import QLabel, QLayout, QMessageBox, QWidget
from ...i18n import tr
from .project_editing import _row_layout_of
def _layout_chua(layout: QLayout, con: QLayout) -> "tuple | None":
"""``(layout_cha, vị_trí)`` của ``con`` bên trong ``layout``, duyệt đệ quy."""
for i in range(layout.count()):
item = layout.itemAt(i)
ben_trong = item.layout()
if ben_trong is con:
return layout, i
if ben_trong is not None:
tim = _layout_chua(ben_trong, con)
if tim is not None:
return tim
return None
def folder_taken_blocked(parent: QWidget, path: str, ignore_id: str) -> bool:
"""``True`` nếu ``path`` đã thuộc project khác — và đã báo cho người dùng.
Dùng chung cho cả ba nơi đặt được thư mục làm việc (nút "Đổi" ở màn
Project, thư mục cloud, và nút chọn thư mục trong tab Cowork), để cả ba
chặn giống hệt nhau thay vì mỗi nơi tự nghĩ ra một luật.
Chặn ở lúc CHỌN chứ không ở lúc Lưu: "Lưu project" chỉ ghi tên, mô tả và
chỉ dẫn — chặn ở đó sẽ khoá luôn việc đổi tên một project lỡ đang trùng
thư mục, tức phạt người dùng vì một trạng thái họ chưa kịp sửa.
"""
from ...core.projects import folder_conflict
khac = folder_conflict(path, ignore_id=ignore_id)
if khac is None:
return False
QMessageBox.warning(parent, tr("workspace.folder_taken_title"),
tr("workspace.folder_taken_body", name=khac.name,
folder=str(khac.workspace_dir())))
return True
class ProjectFolderRuleMixin:
"""Nửa giao diện của luật. Trộn vào ``WorkspaceTab``."""
def install_project_folder_rule(self) -> None:
"""Dựng nhãn cảnh báo và nối nó vào việc đổi project.
Gọi từ ``install_project_editing``, tức sau khi form đã dựng xong.
"""
self._folder_warn_lbl = QLabel()
self._folder_warn_lbl.setObjectName("warning") # màu lấy từ theme/
self._folder_warn_lbl.setWordWrap(True)
self._folder_warn_lbl.hide()
self._gan_nhan_canh_bao_thu_muc()
self.project_selected.connect(self._sync_folder_warning)
def _gan_nhan_canh_bao_thu_muc(self) -> None:
"""Chèn nhãn cảnh báo ngay DƯỚI hàng chứa ô Thư mục làm việc.
Chèn từ đây thay vì thêm dòng vào ``_build_project_tab``: file
``ui/workspace_tab.py`` đang vượt trần của ``scripts/check_loc.py``,
nên mọi dòng mới đều phải tránh nó (cùng lý do nút "Sửa project" được
chèn bằng ``_row_layout_of``).
"""
hang = _row_layout_of(self.folder_lbl)
cha = self.folder_lbl.parentWidget()
if hang is None or cha is None or cha.layout() is None:
return
tim = _layout_chua(cha.layout(), hang)
if tim is None:
return
layout, vi_tri = tim
layout.insertWidget(vi_tri + 1, self._folder_warn_lbl)
def _sync_folder_warning(self, *_a) -> None:
"""Hiện/ẩn cảnh báo "thư mục đang dùng chung" theo project đang mở."""
from ...core.projects import folder_conflict, load_project
pid = getattr(self, "_current_id", "")
project = load_project(pid) if pid else None
khac = (folder_conflict(project.workspace_dir(), ignore_id=pid)
if project is not None else None)
if khac is None:
self._folder_warn_lbl.hide()
return
self._folder_warn_lbl.setText(
tr("workspace.folder_shared_warning", name=khac.name))
self._folder_warn_lbl.show()
+22
View File
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
set "VENV=%APPHOME%\venv" set "VENV=%APPHOME%\venv"
set "LAUNCHER=%APPHOME%\launcher" set "LAUNCHER=%APPHOME%\launcher"
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
rem lai cua so truoc khi in, de thong bao van doc duoc.
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
rem -------------------------------------------------------------------------- rem --------------------------------------------------------------------------
rem 1. Chon trinh thong dich rem 1. Chon trinh thong dich
rem rem
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
) )
if not defined RUNPY ( if not defined RUNPY (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã. echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
echo. echo.
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
if not exist "%VENV%\Scripts\python.exe" ( if not exist "%VENV%\Scripts\python.exe" (
!RUNPY! -c "import PySide6" >nul 2>&1 !RUNPY! -c "import PySide6" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã. echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
echo. echo.
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1 if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1 mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LOI] Khong tao duoc lien ket thu muc: echo [LOI] Khong tao duoc lien ket thu muc:
echo "!PKGPATH!\cowork_local" -> "%REPO%" echo "!PKGPATH!\cowork_local" -> "%REPO%"
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
rem ("'cowork_local' is a package and cannot be directly executed"). rem ("'cowork_local' is a package and cannot be directly executed").
if not exist "!PKGPATH!\cowork_local\__main__.py" ( if not exist "!PKGPATH!\cowork_local\__main__.py" (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan: echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
echo "!PKGPATH!" echo "!PKGPATH!"
@@ -138,10 +150,20 @@ if defined PYTHONPATH (
set "PYTHONIOENCODING=utf-8" set "PYTHONIOENCODING=utf-8"
cd /d "%REPO%" cd /d "%REPO%"
rem --------------------------------------------------------------------------
rem 4. Chay app
rem
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
rem thong bao loi ben duoi van doc duoc.
rem --------------------------------------------------------------------------
!RUNPY! -m cowork_local %* !RUNPY! -m cowork_local %*
set "RC=%ERRORLEVEL%" set "RC=%ERRORLEVEL%"
if not "%RC%"=="0" ( if not "%RC%"=="0" (
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
)
echo. echo.
echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên. echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên.
echo. echo.
+22
View File
@@ -0,0 +1,22 @@
<#
.SYNOPSIS
Ẩn/hiện cửa sổ console hiện tại — dùng bởi run.bat để không hiện cửa sổ
cmd đen suốt phiên chạy app (app là GUI Qt, không cần console), nhưng vẫn
hiện lại được nếu app thoát lỗi để người dùng đọc thông báo.
.PARAMETER Mode
0 = ẩn (SW_HIDE), 5 = hiện lại (SW_SHOW).
#>
param(
[int]$Mode = 0
)
Add-Type -Name Win32 -Namespace CoworkLocalNative -MemberDefinition @"
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
"@
$hwnd = [CoworkLocalNative.Win32]::GetConsoleWindow()
if ($hwnd -ne [IntPtr]::Zero) {
[CoworkLocalNative.Win32]::ShowWindow($hwnd, $Mode) | Out-Null
}
+32
View File
@@ -73,6 +73,38 @@ _MODERATE_PATTERNS = [
r'\b(test|pytest|jest|mocha)\b', r'\b(test|pytest|jest|mocha)\b',
] ]
# Tools that reach the network over ICMP/raw sockets/direct DNS instead of an
# HTTP(S) connection — none of them read HTTP_PROXY/HTTPS_PROXY, so
# core/deps.py::network_blocked_env()'s proxy-env-var block (the only network
# control this sandbox actually enforces) has no effect on them at all. Used
# by command_bypasses_network_proxy() to deny these BY NAME when the user has
# "Chặn mạng cho lệnh do agent chạy" on, since the proxy trick alone silently
# lets them through (see DF-005 in Defect Management).
_NETWORK_PROXY_BYPASS_PATTERNS = [
r'\bping\b', r'\btracert\b', r'\btraceroute\b', r'\bnslookup\b', r'\bdig\b',
r'\btelnet\b', r'\bftp\b', r'\bsftp\b', r'\bscp\b', r'\bssh\b',
r'\bnc\b', r'\bncat\b', r'\bnetcat\b', r'\barp\b',
r'\btest-netconnection\b', r'\btest-connection\b', r'\bresolve-dnsname\b',
]
def command_bypasses_network_proxy(command: str) -> Optional[str]:
"""Tên công cụ mạng đầu tiên khớp trong ``command`` mà không tôn trọng
HTTP_PROXY/HTTPS_PROXY — None nếu không có công cụ nào như vậy.
``network_blocked_env()`` chỉ set biến proxy, nên chỉ chặn được các công
cụ có ĐỌC biến đó (curl/pip/requests...). ``ping`` (ICMP), ``nslookup``
(DNS trực tiếp), ``ssh``/``ftp`` (TCP thô)... đều đi qua giao thức khác,
biến proxy không có tác dụng gì với chúng — phải chặn riêng theo tên lệnh
khi ``block_network`` đang bật.
"""
cmd_lower = command.lower()
for pattern in _NETWORK_PROXY_BYPASS_PATTERNS:
m = re.search(pattern, cmd_lower, re.IGNORECASE)
if m:
return m.group()
return None
def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult: def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult:
"""Chấm điểm rủi ro một lệnh shell. """Chấm điểm rủi ro một lệnh shell.
+9 -1
View File
@@ -233,11 +233,20 @@ class AppContext:
connections across calls/turns (spawning a subprocess per turn would connections across calls/turns (spawning a subprocess per turn would
be slow and wasteful). A server/connector that fails to connect is be slow and wasteful). A server/connector that fails to connect is
skipped, not a hard failure for the turn.""" skipped, not a hard failure for the turn."""
# Sandbox Security Layer blocks agent-owned network connectors before
# they can spawn a server or issue a REST request — and stops the ones
# already running, which could otherwise keep talking to the network.
blocked = bool(self.config.agent_security.get("block_network", False))
if blocked:
self.stop_mcp_connections()
# Master switch (Monitoring → Tools → Connector): when the admin turns # Master switch (Monitoring → Tools → Connector): when the admin turns
# "Connect to external" off, the agent connects to NO external # "Connect to external" off, the agent connects to NO external
# connectors/MCP at all — no subprocesses spawned, no REST calls. # connectors/MCP at all — no subprocesses spawned, no REST calls.
if not self.config.connect_external: if not self.config.connect_external:
return [], None return [], None
from .core.ms365_local import build_ms365_local_tools
if blocked: # the locally-synced OneDrive folders need no network
return build_ms365_local_tools(self.config)
from .core.ext_connectors import build_ext_connector_tools from .core.ext_connectors import build_ext_connector_tools
from .core.mcp_client import build_mcp_tools as _merge_mcp_tools from .core.mcp_client import build_mcp_tools as _merge_mcp_tools
from .core.tools import combine_tool_sources from .core.tools import combine_tool_sources
@@ -272,7 +281,6 @@ class AppContext:
# Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the # Locally-synced OneDrive/SharePoint (no sign-in) — reads/writes the
# OneDrive-desktop-synced folders directly, gated on ms365.connectors. # OneDrive-desktop-synced folders directly, gated on ms365.connectors.
from .core.ms365_local import build_ms365_local_tools
local_tools, local_executor = build_ms365_local_tools(self.config) local_tools, local_executor = build_ms365_local_tools(self.config)
return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor), return combine_tool_sources((mcp_tools, mcp_executor), (ext_tools, ext_executor),
@@ -63,7 +63,7 @@ QUIRK DA DONG DINH (xem case tuong ung ben duoi):
duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False
— du liệu van con trong step, chi khong hien tren UI. — du liệu van con trong step, chi khong hien tren UI.
* ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG, * ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG,
code chuyen sang ``QInputDialog.getText`` thay vi ``getItem`` (dong code chuyen sang ``ask_text`` thay vi ``ask_item`` (dong
401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin 401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot + custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
nhanh code song. nhanh code song.
@@ -98,7 +98,7 @@ import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER) sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from pathlib import Path as _PP from pathlib import Path as _PP
from PySide6.QtWidgets import QApplication, QInputDialog, QFileDialog, QListWidgetItem from PySide6.QtWidgets import QApplication, QFileDialog, QListWidgetItem
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from cowork_local.config import CONFIG_DIR from cowork_local.config import CONFIG_DIR
@@ -269,25 +269,31 @@ assert p.sub_list.count() == before_n, "_step None -> _add_subagent la no-op"
print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK") print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK")
p._step = pstep p._step = pstep
orig_getItem = QInputDialog.getItem # Panel goi ask_item/ask_text cua ui/dialog_buttons (khong con goi thang ham
orig_getText = QInputDialog.getText # tinh QInputDialog.get*: ham tinh tu dung hop thoai ben trong nen nut "Cancel"
# cua no luon tieng Anh). Patch dung namespace cua mixin dang goi — patch vao
# QInputDialog nhu truoc se KHONG chan duoc gi va hop thoai that se mo ra treo.
from cowork_local.presentation.co4e import node_property_actions_mixin as _acts
orig_getItem = _acts.ask_item
orig_getText = _acts.ask_text
changed_n = {"n": 0} changed_n = {"n": 0}
p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1)) p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1))
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Picked Agent", True)) _acts.ask_item = lambda *a, **k: ("Picked Agent", True)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"] assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"] assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"]
assert changed_n["n"] == 1 assert changed_n["n"] == 1
print("CASE_ADD_SUBAGENT_PICKED_OK") print("CASE_ADD_SUBAGENT_PICKED_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True)) _acts.ask_item = lambda *a, **k: ("", True)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them" assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them"
assert changed_n["n"] == 1, "khong them thi khong emit changed" assert changed_n["n"] == 1, "khong them thi khong emit changed"
print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK") print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Should Not Add", False)) _acts.ask_item = lambda *a, **k: ("Should Not Add", False)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them" assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK") print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
@@ -303,12 +309,12 @@ print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
# StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong # StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao. # hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
StepConfigPanel._available_agent_names = staticmethod(lambda: []) StepConfigPanel._available_agent_names = staticmethod(lambda: [])
QInputDialog.getText = staticmethod(lambda *a, **k: ("Typed Agent", True)) _acts.ask_text = lambda *a, **k: ("Typed Agent", True)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"] assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
del StepConfigPanel._available_agent_names del StepConfigPanel._available_agent_names
QInputDialog.getItem = orig_getItem _acts.ask_item = orig_getItem
QInputDialog.getText = orig_getText _acts.ask_text = orig_getText
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK") print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
# ---- _edit_subagent (410-428) ----------------------------------------------- # ---- _edit_subagent (410-428) -----------------------------------------------
@@ -323,7 +329,7 @@ def _fake_get_item(*a, **k):
return ("Renamed", True) return ("Renamed", True)
QInputDialog.getItem = staticmethod(_fake_get_item) _acts.ask_item = _fake_get_item
item0 = p.sub_list.item(0) item0 = p.sub_list.item(0)
p._edit_subagent(item0) p._edit_subagent(item0)
assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0" assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0"
@@ -335,7 +341,7 @@ foreign_item = QListWidgetItem("not in list")
snapshot = dict(captured) snapshot = dict(captured)
p._edit_subagent(foreign_item) p._edit_subagent(foreign_item)
assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op" assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op"
QInputDialog.getItem = orig_getItem _acts.ask_item = orig_getItem
print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK") print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK")
# ---- _del_subagent (430-437) ------------------------------------------------- # ---- _del_subagent (430-437) -------------------------------------------------
+14
View File
@@ -73,3 +73,17 @@ def _bind_checkout_as_package() -> None:
_bind_checkout_as_package() _bind_checkout_as_package()
import pytest # noqa: E402 - after the package binding above
@pytest.fixture(autouse=True)
def _reset_network_guard():
"""``build_context()`` binds the process-wide "Block network" gate to that
context's config (default: blocked). Unbind after every test so one test
that built a real context cannot silently block the network for the rest."""
yield
from cowork_local.application.network import network_guard
network_guard.bind(None)
+68
View File
@@ -0,0 +1,68 @@
"""DF-008 — presentation/folder/ai_file_editor_dialog.py::_AutoExpandInput.
The AI-edit instruction box was a fixed-height single-line QLineEdit (read as
cramped); it is now a QPlainTextEdit that grows with content, submits on
Enter, and inserts a newline on Shift+Enter — same convention as the Cowork
composer's input (presentation/chat/chat_input_box.py::_Input)."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeyEvent
from PySide6.QtCore import QEvent
from cowork_local.presentation.folder.ai_file_editor_dialog import _AutoExpandInput
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def _press_enter(widget, shift: bool = False) -> None:
mods = Qt.ShiftModifier if shift else Qt.NoModifier
event = QKeyEvent(QEvent.KeyPress, Qt.Key_Return, mods)
widget.keyPressEvent(event)
def test_starts_at_min_height(qapp) -> None:
box = _AutoExpandInput()
assert box.height() == _AutoExpandInput.MIN_HEIGHT
def test_grows_with_multiline_content(qapp) -> None:
box = _AutoExpandInput()
start_height = box.height()
box.setPlainText("\n".join(f"line {i}" for i in range(10)))
assert box.height() > start_height
assert box.height() <= _AutoExpandInput.MAX_HEIGHT
def test_enter_emits_submit_and_does_not_insert_newline(qapp) -> None:
box = _AutoExpandInput()
box.setPlainText("hello")
received = []
box.submit.connect(lambda: received.append(True))
_press_enter(box)
assert received == [True]
assert box.toPlainText() == "hello" # Enter did not add a newline
def test_shift_enter_inserts_newline_without_submitting(qapp) -> None:
box = _AutoExpandInput()
box.setPlainText("hello")
cursor = box.textCursor()
cursor.movePosition(cursor.MoveOperation.End)
box.setTextCursor(cursor)
received = []
box.submit.connect(lambda: received.append(True))
_press_enter(box, shift=True)
assert received == []
assert box.toPlainText() == "hello\n"
+132
View File
@@ -0,0 +1,132 @@
"""DF-007 — core/cloud_workspace_sync.py: mirror a cloud folder to/from a
local directory. All Graph calls are faked (monkeypatch on the ``graph``
module the sync module imports) — no network."""
from __future__ import annotations
from pathlib import Path
import pytest
from cowork_local.core import cloud_workspace_sync as sync
from cowork_local.core import ms365_graph as graph
def _fake_tree():
"""root/
a.txt
sub/
b.txt
"""
files = {"a.txt": b"hello", "sub/b.txt": b"world"}
listing = {
"": [{"name": "a.txt"}, {"name": "sub", "folder": {}}],
"sub": [{"name": "b.txt"}],
}
return files, listing
def test_download_folder_mirrors_tree(tmp_path: Path, monkeypatch) -> None:
files, listing = _fake_tree()
def fake_list_onedrive_files(token, path=""):
return listing.get(path, [])
def fake_download_bytes(token, path):
return files[path]
monkeypatch.setattr(graph, "list_onedrive_files", fake_list_onedrive_files)
monkeypatch.setattr(graph, "download_onedrive_file_bytes", fake_download_bytes)
local_dir = tmp_path / "mirror"
report = sync.download_folder("tok", {"provider": "onedrive", "remote_path": ""}, local_dir)
assert report.transferred == 2
assert report.errors == []
assert (local_dir / "a.txt").read_bytes() == b"hello"
assert (local_dir / "sub" / "b.txt").read_bytes() == b"world"
def test_download_folder_collects_errors_without_raising(tmp_path: Path, monkeypatch) -> None:
def fake_list_onedrive_files(token, path=""):
raise graph.Ms365GraphError("boom")
monkeypatch.setattr(graph, "list_onedrive_files", fake_list_onedrive_files)
local_dir = tmp_path / "mirror"
report = sync.download_folder("tok", {"provider": "onedrive", "remote_path": ""}, local_dir)
assert report.transferred == 0
assert len(report.errors) == 1
assert "boom" in report.errors[0]
def test_upload_folder_pushes_every_file(tmp_path: Path, monkeypatch) -> None:
local_dir = tmp_path / "mirror"
(local_dir / "sub").mkdir(parents=True)
(local_dir / "a.txt").write_bytes(b"hello")
(local_dir / "sub" / "b.txt").write_bytes(b"world")
uploaded = {}
def fake_upload_bytes(token, path, data):
uploaded[path] = data
return {}
monkeypatch.setattr(graph, "upload_onedrive_file_bytes", fake_upload_bytes)
report = sync.upload_folder("tok", {"provider": "onedrive", "remote_path": "work"}, local_dir)
assert report.transferred == 2
assert uploaded == {"work/a.txt": b"hello", "work/sub/b.txt": b"world"}
def test_upload_folder_reports_files_over_the_simple_upload_limit(tmp_path: Path, monkeypatch) -> None:
local_dir = tmp_path / "mirror"
local_dir.mkdir()
(local_dir / "big.bin").write_bytes(b"x")
def fake_upload_bytes(token, path, data):
raise graph.Ms365GraphError("File too large for simple upload (huge > 4 bytes)")
monkeypatch.setattr(graph, "upload_onedrive_file_bytes", fake_upload_bytes)
report = sync.upload_folder("tok", {"provider": "onedrive", "remote_path": ""}, local_dir)
assert report.transferred == 0
assert report.skipped_too_large == ["big.bin"]
assert report.errors == []
def test_upload_size_guard_rejects_before_any_request(monkeypatch) -> None:
huge = b"x" * (graph.MAX_SIMPLE_UPLOAD_BYTES + 1)
def fail_if_called(*a, **k): # pragma: no cover - must not be reached
raise AssertionError("_request should not be called for an oversized upload")
monkeypatch.setattr(graph, "_request", fail_if_called)
with pytest.raises(graph.Ms365GraphError, match="too large"):
graph.upload_onedrive_file_bytes("tok", "a.bin", huge)
def test_sharepoint_provider_uses_site_scoped_calls(tmp_path: Path, monkeypatch) -> None:
seen = {}
def fake_list_sharepoint_files(token, site_id, path=""):
seen["list_site_id"] = site_id
return [{"name": "a.txt"}] if path == "" else []
def fake_download_sharepoint_bytes(token, site_id, path):
seen["download_site_id"] = site_id
return b"hi"
monkeypatch.setattr(graph, "list_sharepoint_files", fake_list_sharepoint_files)
monkeypatch.setattr(graph, "download_sharepoint_file_bytes", fake_download_sharepoint_bytes)
local_dir = tmp_path / "mirror"
cloud_source = {"provider": "sharepoint", "site_id": "site-123", "remote_path": ""}
report = sync.download_folder("tok", cloud_source, local_dir)
assert report.transferred == 1
assert seen["list_site_id"] == "site-123"
assert seen["download_site_id"] == "site-123"
+55
View File
@@ -0,0 +1,55 @@
"""DF-002 (phần b) — node đang chạy HOẶC đã chạy xong không cho edit thông
tin trong Node. Trước khi sửa, ``_LOCKED_NODE_STATUSES`` khoá cả STEP_RUNNING
lẫn STEP_DONE, nên một bước đã chạy xong không bao giờ sửa lại được nữa.
Fix: chỉ khoá khi bước ĐANG chạy (STEP_RUNNING) — chạy xong rồi thì mở khoá
trở lại. Test này chốt cả nguồn sự thật (tuple
``co4e_workflow_crud._LOCKED_NODE_STATUSES``) lẫn hành vi ở widget
(``StepConfigPanel.set_locked``), để không bị hồi quy về hành vi cũ.
"""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
from cowork_local.core.co4e import (
STEP_DONE, STEP_ERROR, STEP_IDLE, STEP_PLANNED, STEP_RUNNING,
)
from cowork_local.presentation.co4e.co4e_workflow_crud import _LOCKED_NODE_STATUSES
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def test_only_running_status_is_locked() -> None:
"""Một bước đã chạy xong (STEP_DONE) phải sửa lại được — chỉ bước đang
thực sự chạy (STEP_RUNNING) mới bị khoá."""
assert _LOCKED_NODE_STATUSES == (STEP_RUNNING,)
assert STEP_DONE not in _LOCKED_NODE_STATUSES
assert STEP_IDLE not in _LOCKED_NODE_STATUSES
assert STEP_ERROR not in _LOCKED_NODE_STATUSES
assert STEP_PLANNED not in _LOCKED_NODE_STATUSES
def test_set_locked_disables_then_reenables_edit_fields(qapp) -> None:
panel = StepConfigPanel()
panel.setEnabled(True) # panel starts disabled until a step is loaded
panel.set_locked(True)
assert not panel.label_edit.isEnabled()
assert not panel.instructions_edit.isEnabled()
assert not panel.model_combo.isEnabled()
panel.set_locked(False)
assert panel.label_edit.isEnabled()
assert panel.instructions_edit.isEnabled()
assert panel.model_combo.isEnabled()
+140
View File
@@ -0,0 +1,140 @@
"""DF-006 — the "Số dòng/trang" (rows per page) control plus real prev/next
pagination: EventTable's page-size/page-index state
(presentation/monitoring/shared/event_table.py) and its QComboBox + pager
button wiring in build_filter_scaffold (.../shared/filter_scaffold.py).
Options are 5/10/20/50/100 with a next/prev pager, per the QA follow-up on
DF-006 — the earlier fix only trimmed to a page size (dropping every row past
it with no way back); this exercises the real paging end to end."""
from __future__ import annotations
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
QWidget = pytest.importorskip("PySide6.QtWidgets").QWidget
from cowork_local.presentation.monitoring.shared.event_table import (
PAGE_SIZE_OPTIONS, EventTable,
)
from cowork_local.presentation.monitoring.shared.filter_scaffold import build_filter_scaffold
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
def _events(n: int):
return [{"ts": f"2026-09-0{i % 9 + 1}T00:00:0{i % 9}", "kind": "tool_call",
"name": f"e{i}", "ok": True, "detail": ""} for i in range(n)]
def test_page_size_options_are_5_10_20_50_100() -> None:
assert PAGE_SIZE_OPTIONS == (5, 10, 20, 50, 100)
def test_default_page_size(qapp) -> None:
table = EventTable()
assert table.page_size() == 20
table.set_events(_events(45))
assert table.rowCount() == 20
assert table.page_count() == 3
assert table.current_page() == 0
def test_set_page_size_resets_to_first_page(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.next_page()
assert table.current_page() == 1
table.set_page_size(50)
assert table.page_size() == 50
assert table.current_page() == 0
assert table.rowCount() == 45 # only 45 events total, fits in one page of 50
def test_next_prev_page_navigate_without_dropping_rows(qapp) -> None:
table = EventTable()
table.set_events(_events(45))
table.set_page_size(20)
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 1
assert table.rowCount() == 20
table.next_page()
assert table.current_page() == 2
assert table.rowCount() == 5 # last page: remainder
table.next_page() # already on last page — stays put
assert table.current_page() == 2
table.prev_page()
assert table.current_page() == 1
table.prev_page()
table.prev_page() # already on first page — stays put
assert table.current_page() == 0
def test_page_changed_signal_reports_current_and_total(qapp) -> None:
table = EventTable()
seen = []
table.page_changed.connect(lambda cur, total: seen.append((cur, total)))
table.set_events(_events(45))
table.set_page_size(20)
table.next_page()
assert seen[-1] == (1, 3)
def test_page_size_combo_is_only_added_when_requested(qapp) -> None:
page = QWidget()
table = EventTable()
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=False)
assert "page_size_combo" not in parts
assert "page_prev_btn" not in parts
def test_page_size_combo_changes_the_table(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
combo = parts["page_size_combo"]
assert combo.count() == len(PAGE_SIZE_OPTIONS)
assert combo.currentData() == 20 # matches EventTable's current page_size
idx = PAGE_SIZE_OPTIONS.index(10)
combo.setCurrentIndex(idx)
assert table.page_size() == 10
assert table.rowCount() == 10
def test_pager_buttons_disable_at_bounds_and_indicator_updates(qapp) -> None:
page = QWidget()
table = EventTable()
table.set_events(_events(45))
parts = build_filter_scaffold(page, table, on_refresh=lambda: None, with_page_size=True)
table.set_page_size(20)
prev_btn, next_btn = parts["page_prev_btn"], parts["page_next_btn"]
indicator = parts["page_indicator_label"]
assert not prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 1/3"
next_btn.click()
assert prev_btn.isEnabled()
assert next_btn.isEnabled()
assert indicator.text() == "Trang 2/3"
next_btn.click()
assert prev_btn.isEnabled()
assert not next_btn.isEnabled()
assert indicator.text() == "Trang 3/3"
+6 -3
View File
@@ -35,11 +35,14 @@ def test_construction_and_retranslate(qapp, tmp_path) -> None:
def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None: def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
from PySide6.QtWidgets import QInputDialog
ctx = _FakeCtx(tmp_path) ctx = _FakeCtx(tmp_path)
panel = PricingPanel(ctx, on_status_message=lambda _m: None) panel = PricingPanel(ctx, on_status_message=lambda _m: None)
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True))) # Panel hỏi tên qua ``ui.dialog_buttons.ask_text`` chứ không gọi thẳng hàm
# tĩnh ``QInputDialog.getText`` (hàm tĩnh tự dựng hộp thoại bên trong nên
# nút Cancel của nó luôn tiếng Anh). Patch nhầm chỗ thì hộp thoại THẬT mở
# ra và test treo cho tới khi hết giờ.
monkeypatch.setattr("cowork_local.ui.dialog_buttons.ask_text",
lambda *a, **k: ("gpt-test", True))
panel._add_pricing_row() panel._add_pricing_row()
assert panel.table.rowCount() == 1 assert panel.table.rowCount() == 1
assert panel.table.item(0, 0).text() == "gpt-test" assert panel.table.item(0, 0).text() == "gpt-test"

Some files were not shown because too many files have changed in this diff Show More