Compare commits

..
Author SHA1 Message Date
vudt15andClaude Sonnet 5 f6c196ca87 fix(graphrag): crash on project rename/path change (DF-009)
session_events.py::_on_projects_changed (runs on every project
create/rename/delete) called self.structure._refresh_project_combo() —
but that method only ever existed on GraphRenderer, not on
StructureGraphView itself (self.structure). Renaming a project or
changing its working-directory path raised AttributeError and crashed
the app.

Add StructureGraphView.refresh_project_list(), matching the existing
schedule_rescan/set_project/prewarm forwarding pattern, and call that
instead. Add a regression test
(test_refresh_project_list_forwards_to_renderer).

Logged as DF-009 in Task Tracking Template.xlsx — root cause, fix and
regression test recorded (row was empty in this local copy despite
being reported as already logged there).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-15 09:46:33 +09:00
vudt15andClaude Sonnet 5 a20c7a2d69 docs(qa): log DF-007 investigation — feature already shipped, no fix needed
CI / test (pull_request) Canceled after 0s
Khảo sát cho thấy DF-007 (chọn thư mục làm việc là link cloud
OneDrive/SharePoint thay vì chỉ local) đã được xây dựng đầy đủ từ
commit f9f6bc0, trước cả đợt fix df002/df011. Không sửa code — chỉ
ghi lại bằng chứng vào Task Tracking Template.xlsx và đề nghị QA test
lại trên bản build mới nhất.

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:19:02 +09:00
vudt15andClaude Sonnet 5 4f88171c64 fix(qa): resolve DF-002/DF-003/DF-006, log Defect Management sheet
DF-002 (Co4E): node đã chạy xong (STEP_DONE) vẫn bị khoá edit — chỉ khoá
khi node đang thực sự chạy (STEP_RUNNING). Phần "status sai khi back
tab" đã được xử lý từ trước (2a5ee29/13e2c22), xác minh vẫn nguyên vẹn.

DF-003: run.bat chỉ ẩn console ngay trước lúc chạy app, nên các bước
kiểm tra (Python/thư viện/junction) phía trước vẫn hiện console một
chút — ẩn ngay từ đầu file, hiện lại đúng lúc báo lỗi.

DF-006 (Monitoring): options số dòng/trang đổi thành 5/10/20/50/100
(mặc định 20); EventTable giờ phân trang thật (next/prev, page_count)
thay vì chỉ cắt top-N rồi bỏ phần còn lại.

Thêm tests/test_co4e_edit_lock.py (chưa có test nào phủ hành vi khoá
edit trước đó); viết lại tests/test_monitoring_page_size.py cho đúng
options/phân trang mới.

Cập nhật Task Tracking Template.xlsx — điền Bước tái hiện/Root Cause/
Cách xử lý/Regression Test/Evidence cho DF-002, DF-003, DF-005, DF-006
và dòng "Cowork/GraphRAG chỉ hiện khi chọn project" (qua Excel COM, để
không làm mất shape/data-validation nâng cao khác trong file).

Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-14 12:07:13 +09:00
duylh19andClaude Opus 5 57af508971 fix(sandbox): chặn mạng thật sự cho mọi tool NETWORK của agent
CI / test (pull_request) Canceled after 0s
block_network trước đây chỉ được run_command đọc tới, nên fetch_url,
jira_search, jira_get_issue và install_package vẫn ra internet bình thường
trong khi Monitoring báo Mạng - Bị chặn. Thêm cổng chặn ngay đầu bốn tool
đó, trước mọi lời gọi mạng, kèm test hồi quy và guardrail theo
BUILT_IN_CAPABILITIES.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-12 08:33:26 +09:00
duylh19andClaude Opus 5 d0df96c726 fix(workspace): Cowork/GraphRAG chỉ hiện khi đã chọn project
Mở app lên là hai mục đã hiện sẵn dù người dùng chưa bấm chọn project nào.
Cổng này có hai mặt và cả hai đều hở:

1. refresh() mặc định row_to_select = 0, nên lúc mở app (_current_id rỗng)
   danh sách tự chọn hộ project đầu tiên -> _update_tab_visibility(True).
   Cùng đường đó còn làm app nhảy sang project khác ngay sau khi xoá project
   đang mở. Đổi mặc định thành -1; có keep khớp thì vẫn giữ đúng dòng cũ.

2. NavRailMixin._rebuild_nav dựng hàng bị cổng đóng ở dạng mờ kèm tooltip
   ("shown instead of hidden") nên chúng vẫn nằm trên menu trái. Nay bỏ hẳn
   hàng cho tới khi chọn project.

Mặt 1 làm selected_project_id() trả rỗng, khiến bộ chọn project trên rail rơi
về mục 0 và chỉ vào project đầu danh sách trong khi cổng vẫn đóng — hai chỗ
nói hai đằng. Thêm mục "Chọn project…" (khoá app.nav.pick_project, khác
no_project: đã có project, chỉ chưa chọn) để rail nói đúng trạng thái.

tools/check_nav.py và một bài trong test_startup_landing.py đang chốt thiết
kế cũ; cập nhật để chốt hành vi mới. Bài startup giữ nguyên từng assert, chỉ
đổi cách mở cổng từ cửa sau setTabVisible sang chọn project thật.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:32:08 +09:00
duylh19andClaude Opus 5 10799dc67b feat(settings): bỏ khoá mật khẩu ở Sandbox Security Layer
Bốn công tắc của nhóm này (xác nhận lệnh, chặn mạng, bật lớp bảo mật agent,
AI kiểm tra lệnh) dựng ra ở trạng thái setEnabled(False) và chỉ mở khi nhập
đúng mật khẩu. Theo yêu cầu, bỏ hẳn bước đó: form luôn bật/tắt được.

Gỡ ô nhập, nút Mở khoá, nhãn trạng thái khoá, _sandbox_unlock() và
_sandbox_password_matches(); dọn 10 khoá i18n thành chết và 2 field trong
tools/check_dialogs.py. Giữ nguyên agent_security.sandbox_pw ở config.py —
yêu cầu chỉ nói tới màn hình, không nói tới tầng cấu hình.

Khoá này vốn không phải rào bảo mật: docstring của _sandbox_unlock() đã tự
ghi "khoá phía giao diện để chặn bấm nhầm ... KHÔNG phải cơ chế bảo mật
thật". Rào thật nằm ở sandbox lúc chạy lệnh. Vùng này thuộc diện SECURITY.md
yêu cầu Cowork Team soát thêm.

10 bài test cũ (SEC-20260907-01) chốt các đường không được mở khoá nay mất
đối tượng kiểm, thay bằng 12 bài chốt hành vi mới: bốn công tắc sửa được
ngay, không còn widget mật khẩu, kèm guardrail quét mã nguồn chặn khoá lại.

Co-Authored-By: Claude Opus 5 <noreply@anthropic.com>
2026-09-11 15:31:49 +09: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
97 changed files with 2998 additions and 563 deletions
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.
"""
__version__ = "2.26.0"
__version__ = "0.0.1"
# Internal/technical name — config dir (~/.cowork_local), QSettings org keys,
# packaging scripts and docs still use this; do NOT rebrand it.
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
`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
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á?
* [ ] 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ữ?
* [ ] 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.
"""
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._extra_tools = list(extra_tools or ())
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
+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_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (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
# Cut the agent off the network: proxy env pointed at a black hole for
# agent-run shell commands, PLUS a flat refusal from every tool tagged
# ToolCapability.NETWORK (fetch_url, jira_*, install_package) — those
# reach the net in-process, where the proxy trick has nothing to act on.
"block_network": True,
# Allow the agent's fetch_url tool to read web pages / online documents /
# SharePoint-OneDrive share links. SEPARATE from block_network (that only
# sandboxes agent-run shell commands) — reading a URL for info is safe and
# useful, so this defaults ON. Toggle in Settings → Security.
# SharePoint-OneDrive share links. Its own toggle — reading a URL for info
# is safe and useful, so this defaults ON — but block_network outranks it:
# with the network blocked the tool is refused either way.
"allow_url_fetch": True,
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
+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
+52
View File
@@ -196,6 +196,40 @@ def write_onedrive_file(token: str, path: str, content: str) -> dict:
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:
"""Encode a OneDrive/SharePoint sharing URL into Graph's ``u!<base64url>``
share-id form (see Microsoft's 'Get access to shared items' docs)."""
@@ -229,6 +263,24 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
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 ------------------------------------------
def find_online_meeting(token: str, join_url: str) -> List[dict]:
"""Tìm cuộc họp online theo link tham gia."""
+7
View File
@@ -65,6 +65,13 @@ class Project:
# auto_run: None → follow the global agent_security.cowork_confirm_commands;
# True → auto-approve commands (no confirm); False → always confirm.
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:
"""The project's sandbox root. Every chat of the project writes inside
+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,
depends_on: List[str]) -> Optional[str]:
"""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 []):
return "A task cannot wait for itself."
return "schedtask.err_self_wait"
by_id = {t["task_id"]: t for t in tasks}
# DFS from each proposed prerequisite through ITS prerequisites.
for start in depends_on or []:
@@ -268,7 +272,7 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
while stack:
cur = stack.pop()
if cur == task_id:
return "This would create a circular wait between tasks."
return "schedtask.err_wait_cycle"
if cur in seen:
continue
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,
next_task_id: Optional[str]) -> Optional[str]:
"""Validate assigning ``next_task_id`` as ``task_id``'s next task.
Returns an error string (self-link / circular chain / unknown id), or
None when the assignment is safe."""
Returns an i18n KEY for the problem (self-link / circular chain / unknown
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:
return None
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}
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
# the new edge would close a cycle.
seen = {task_id}
cur = next_task_id
while cur:
if cur in seen:
return "This would create a circular task chain."
return "schedtask.err_chain_cycle"
seen.add(cur)
cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id")
return None
+6
View File
@@ -48,6 +48,9 @@ from . import skills_dialog as _skills_dialog
from . import libreoffice_view as _libreoffice_view
from . import agents_admin_tab as _agents_admin_tab
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ả
# còn nằm chung một dict literal.
@@ -62,6 +65,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
**_libreoffice_view.STRINGS,
**_agents_admin_tab.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": "行をフィルター(質問を入力しても可)…",
"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.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": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"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.",
"ja": "実行前に制御エージェントがAIでコマンドを確認します。",
"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": {
"en": "Confirm before Cowork runs a command",
"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.",
"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."},
# 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. "
"Connector MCP / REST-API được thiết lập ở tab con Connector."},
"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": {
"en": "Web access (fetch_url)", "ja": "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.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.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_tooltip": {
"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"},
"app.nav.no_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.all_projects": {
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
+13 -1
View File
@@ -79,7 +79,10 @@ STRINGS: Dict[str, Dict[str, str]] = {
"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.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.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
"app.tray.running_body": {
@@ -197,6 +200,15 @@ STRINGS: Dict[str, Dict[str, str]] = {
"chat.provider_default_short": {
"en": "the provider's default model", "ja": "プロバイダー既定のモデル",
"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_tooltip": {
"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.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()
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)
return {"ok": False, "output": denial}
# Every sandbox backend's network block is a proxy-env-var trick (see
# core/deps.py::network_blocked_env) — it does nothing against a tool
# that reaches the network without an HTTP proxy (ping/ICMP, nslookup/
# direct DNS, ssh/ftp/raw TCP...). Deny those BY NAME here instead, so
# "Chặn mạng cho lệnh do agent chạy" actually blocks them too.
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
mgr = SandboxManager(ExecutionConfig(
enabled=True,
@@ -111,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any],
package = str(args.get("package", "")).strip()
if not package:
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)
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}."
+25 -1
View File
@@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes.
"""
from __future__ import annotations
from typing import Any, Dict
from typing import Any, Dict, Optional
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]:
"""Fetch a URL's text content (web page / online document / SharePoint-
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."}
if not url.lower().startswith(("http://", "https://")):
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:
return {"ok": False,
"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]:
"""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
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]:
"""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
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.
resource_limits: Optional[Dict[str, float]] = None
# 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.
block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network
# (reading a web page/share link for info is safe; running networked shell
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch.
# Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way
# around block_network: with the network blocked every NETWORK-capability
# 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
# Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"].
+10 -5
View File
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
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.ui.icons import icon
@@ -57,7 +57,7 @@ class AudioRecorderWidget(QWidget):
# Record / Stop toggle button
self.record_btn = QPushButton()
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.clicked.connect(self.toggle_recording)
layout.addWidget(self.record_btn)
@@ -78,7 +78,7 @@ class AudioRecorderWidget(QWidget):
self.cancel_btn = QPushButton()
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.clicked.connect(self.cancel_recording)
status_layout.addWidget(self.cancel_btn)
@@ -106,7 +106,7 @@ class AudioRecorderWidget(QWidget):
self.timer_label.setText("00:00")
self.status_container.setVisible(True)
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._timer.start()
self.recording_started.emit()
@@ -137,7 +137,12 @@ class AudioRecorderWidget(QWidget):
self.status_container.setVisible(False)
self.record_btn.setIcon(icon("microphone"))
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:
"""Update recording duration display every second."""
+1 -1
View File
@@ -141,7 +141,7 @@ class ChatAgentsMixin:
if not items and self.agent_combo.count() == 0:
# No models found and none configured — placeholder with data=None so
# 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}"
if getattr(self, "_admin_agent", None) is not None else keep)
idx = self.agent_combo.findData(keep_data) if keep_data else -1
+2 -2
View File
@@ -11,9 +11,9 @@ from __future__ import annotations
from pathlib import Path
from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox
from ...core.worker import AgentWorker
from ...i18n import tr
from ...ui.dialog_buttons import confirm
class ChatSessionMixin:
@@ -308,7 +308,7 @@ class ChatSessionMixin:
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
else:
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
for bubble in turn.get("bubbles", []):
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 ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node
from ...i18n import tr
from ...theme import current_palette
from .canvas_geometry import _elide, _rounded_path
@@ -199,6 +200,14 @@ class _NodeItem(QGraphicsObject):
e.accept()
return
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):
"""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):
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
menu = QMenu()
a_add = menu.addAction("+ Add next step")
a_conn = menu.addAction("→ Connect from here")
a_del = menu.addAction("🗑 Delete step")
a_add = menu.addAction("+ " + tr("co4e.canvas_add_next"))
a_conn = menu.addAction("→ " + tr("co4e.canvas_connect_from"))
a_del = menu.addAction("🗑 " + tr("co4e.delete_step"))
chosen = menu.exec(e.screenPos())
if chosen is a_add:
self.canvas.add_step_below(self.node.id)
@@ -334,7 +343,7 @@ class _EdgeItem(QGraphicsPathItem):
def contextMenuEvent(self, e):
"""Menu chuột phải trên đường nối: xoá liên kết."""
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:
self.canvas.delete_edge(self.edge)
e.accept()
+9
View File
@@ -273,6 +273,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
item.status = status
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:
"""Đư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():
+5
View File
@@ -101,6 +101,11 @@ class Co4EFlowTabsMixin:
self.center_stack.setCurrentIndex(1)
self._sync_runs_toggle(False)
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:
"""Keep the Runs toggle showing which page is up, however it got there
(a double-click in the runs table also switches pages)."""
+13 -5
View File
@@ -13,10 +13,11 @@ import re
from pathlib import Path
from typing import Dict, List, Optional
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 ...i18n import tr
from ...theme import current_palette
from .co4e_workflow_crud import _LOCKED_NODE_STATUSES
class Co4ERunsMixin:
@@ -155,7 +156,14 @@ class Co4ERunsMixin:
t = ev.get("type")
if t == "node_status":
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":
if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
@@ -329,9 +337,9 @@ class Co4ERunsMixin:
h = self.manager.get(run_id)
if h is None:
return
from PySide6.QtWidgets import QInputDialog
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
from ...ui.dialog_buttons import ask_text
new, ok = ask_text(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip()
if not ok or not new or new == h.name:
return
+13 -4
View File
@@ -8,12 +8,16 @@ from __future__ import annotations
import re
from typing import List, Optional
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu
from PySide6.QtWidgets import QMenu
from ...core import co4e
from ...core.co4e import STEP_RUNNING
from ...i18n import tr
from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
class Co4EWorkflowCrudMixin:
"""Phần tạo/mở/lưu/xoá luồng của Co4E Studio.
@@ -115,8 +119,8 @@ class Co4EWorkflowCrudMixin:
wf = co4e.get_workflow(ident)
if wf is None:
return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name, ok = ask_text(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name)
name = (name or "").strip()
if not ok or not name:
return
@@ -163,10 +167,15 @@ class Co4EWorkflowCrudMixin:
self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center()))
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():
if n.id == node_id:
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:
self._toggle_config()
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ó
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ó
vẻ thừa như ``from PySide6.QtWidgets import QInputDialog`` lặp lại bên trong
``_add_subagent``/``_edit_subagent`` dù đã có ở top-level) — chỉ số cấp `..`
được nâng lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
``presentation/co4e/`` (cách gốc 3 cấp).
Import trong từng method giữ nguyên y hệt bản gốc — chỉ số cấp `..` được nâng
lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
``presentation/co4e/`` (cách gốc 3 cấp). Riêng các lời gọi ``QInputDialog``
đã chuyển sang ``ui.dialog_buttons``: hàm tĩnh của Qt tự dựng hộp thoại bên
trong nên nút "Cancel" của nó luôn là tiếng Anh.
"""
from __future__ import annotations
from typing import List
from PySide6.QtWidgets import QInputDialog, QListWidgetItem
from PySide6.QtWidgets import QListWidgetItem
from ...core.co4e import SubAgent
from ...i18n import tr
from ...ui.dialog_buttons import ask_item, ask_multiline, ask_text
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 đó)."""
if self._step is None:
return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names()
if names:
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one
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()
if not ok or not name:
return
@@ -89,13 +88,11 @@ class _StepConfigActionsMixin:
row = self.sub_list.row(item)
if not (0 <= row < len(self._step.sub_agents)):
return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names()
cur = self._step.sub_agents[row].agent
start = names.index(cur) if cur in names else 0
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names or [cur], start, True)
name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names or [cur], start, True)
name = (name or "").strip()
if ok and name:
self._step.sub_agents[row].agent = name
@@ -151,7 +148,7 @@ class _StepConfigActionsMixin:
role = self.role_edit.text().strip()
if not name and not role:
return
hint, ok = QInputDialog.getMultiLineText(
hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok:
return
+28 -5
View File
@@ -38,7 +38,7 @@ from PySide6.QtWidgets import (
)
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 ...ui.icons import icon, icon_picker_combo
from .node_property_actions_mixin import _StepConfigActionsMixin
@@ -65,6 +65,8 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self._step: Optional[Step] = None
self._node_id = ""
self._loading = False
self._ctx_available = ctx is not None
self._locked = False
self.setWidgetResizable(True)
host = QWidget()
self.setWidget(host)
@@ -77,7 +79,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# (skills/files/sub-agents). No tabs/accordion: every group's border
# and heading are what separate it from its neighbours, and all three
# 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.textChanged.connect(self._on_edit)
@@ -122,7 +124,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.context_edit.textChanged.connect(self._on_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()
self.model_combo = QComboBox()
@@ -161,7 +163,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
vrow = QWidget(); vrow.setLayout(verify_row)
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)
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
# only applies to parallel-variant steps. load_step() hides the whole
# 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.setMaximumHeight(90)
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._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:
"""Xoá bảng khi không có bước nào được chọn."""
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.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
from ...i18n import bind_dynamic, tr
from ...theme import current_palette
_SECTION_ANIM_MS = 180
@@ -64,7 +65,7 @@ class _SectionHeader(QLabel):
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
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
@@ -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 +
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
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()
card = QWidget()
card_lay = QVBoxLayout(card)
@@ -96,7 +101,6 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
# diacritics.
header.ensurePolished()
header.setFixedHeight(header.fontMetrics().height())
header.setText(f"▶ {title}")
card_lay.addWidget(header)
body = QWidget()
@@ -112,6 +116,14 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
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:
"""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.
@@ -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."""
nonlocal is_open
is_open = not is_open
header.setText(f"{'▼' if is_open else '▶'} {title}")
_sync_header()
anim.stop()
if is_open:
body.setVisible(True)
+3 -3
View File
@@ -116,9 +116,9 @@ class HabitsWidget(QWidget):
"""Apply an AI-suggested cost-saving strategy AFTER the user
approves: turn on auto-compress and compress earlier (lower
threshold) + compress content before sending it to the agent."""
from PySide6.QtWidgets import QMessageBox
if QMessageBox.question(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")) != QMessageBox.Yes:
from ...ui.dialog_buttons import confirm
if not confirm(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")):
return
cx = self.ctx.config.data.setdefault("context", {})
cx["auto_compact"] = True
+3 -3
View File
@@ -290,9 +290,9 @@ class AiEditPipeline:
self.pending = None
self._owner.show_confirm_row(False)
if p.get("image_gens"):
from PySide6.QtWidgets import QMessageBox
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes:
from ...ui.dialog_buttons import confirm
if not confirm(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")):
self._owner.status_message.emit(tr("folder.ai_image_declined"))
return
self._generate_then_finalize(p)
+44 -5
View File
@@ -21,9 +21,9 @@ from __future__ import annotations
from typing import List, Optional
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.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
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):
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's
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)
row = QHBoxLayout()
self.ai_input = QLineEdit()
self.ai_input = _AutoExpandInput()
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)
self.ai_send_btn = QPushButton(tr("folder.ai_send"))
self.ai_send_btn.setObjectName("primary")
@@ -170,7 +209,7 @@ class AiFileEditorDialog(QWidget):
if not self.preview.root:
self.ai_chat.add_error(tr("folder.ai_no_file"))
return
instruction = self.ai_input.text().strip()
instruction = self.ai_input.toPlainText().strip()
if not instruction:
return
self.ai_input.clear()
@@ -276,10 +276,9 @@ class OfficeDocumentRenderer:
from cowork_local.core import pptx_edit
o = self._owner
if not skip_confirm and pptx_edit.image_change_requested(content):
from PySide6.QtWidgets import QMessageBox
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm"))
if ok != QMessageBox.Yes:
from cowork_local.ui.dialog_buttons import confirm
if not confirm(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm")):
o.status_message.emit(tr("folder.ai_image_declined"))
return False
pptx_edit.apply_text_to_pptx(o.current_file, content)
@@ -219,6 +219,12 @@ class StructureGraphView(QWidget):
"""Dựng sẵn khung đồ thị trước khi người dùng bấm vào, để lần mở đầu không giật."""
self.renderer.prewarm()
def refresh_project_list(self) -> None:
"""Project khác vừa được tạo/sửa/xoá/đổi tên — làm mới danh sách trong
bộ chọn project của renderer (bộ chọn KHÔNG tự nạp lại khi project
thay đổi ở màn khác, chỉ khi ``set_project`` được gọi)."""
self.renderer._refresh_project_combo()
def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info.
"""Rời màn GraphRAG thì xoá phần trích xuất tạm của khung hỏi-đáp."""
+27 -6
View File
@@ -10,6 +10,7 @@ the ``status_message`` signal, ``select_subtab(index)``, ``nav_subtabs()``,
"""
from __future__ import annotations
from datetime import date, timedelta
from typing import List
from PySide6.QtCore import QTimer, Signal
@@ -25,11 +26,25 @@ from .tabs.overview_tab import OverviewTab
from .tabs.security_events_tab import SecurityEventsTab
_REFRESH_MS = 3000
# Comfortably larger than any realistic audit-log size — the event tables
# have never had pagination controls, so every tab still shows "all matching
# events" exactly like before; MonitoringQueryService's pagination support
# is exercised for real here, just not surfaced as UI (yet).
# Comfortably larger than any realistic audit-log size for the WINDOW of
# events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this
# is MonitoringQueryService's query-side page size, kept unbounded so it
# 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
# _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):
@@ -259,14 +274,20 @@ 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
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
if shared_dir:
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:
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:
"""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 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.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 .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):
@@ -62,6 +63,12 @@ class EventTable(QTableWidget):
"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):
# 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.
@@ -70,6 +77,10 @@ class EventTable(QTableWidget):
là thất bại nên cột ấy chỉ tốn chỗ.
"""
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)
self.setEditTriggers(QTableWidget.NoEditTriggers)
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")]
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:
"""Đổ 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
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.setRowCount(len(events))
for row, ev in enumerate(events):
@@ -149,6 +207,7 @@ class EventTable(QTableWidget):
self.setItem(row, col, item)
self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", ""))
self.page_changed.emit(self._current_page, self.page_count())
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)."""
@@ -16,13 +16,13 @@ from typing import Callable, Dict, Optional
from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter,
QTableWidget, QVBoxLayout, QWidget,
QApplication, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QSplitter, QTableWidget, QVBoxLayout, QWidget,
)
from ....i18n import bind_tip, tr
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
@@ -47,11 +47,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
def build_filter_scaffold(
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
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,
) -> 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,
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ỏ
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))
row.addWidget(search, 1)
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)
parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -25,13 +25,16 @@ class ActionLogsTab(QWidget):
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
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_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
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:
"""Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +47,8 @@ class ActionLogsTab(QWidget):
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
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:
"""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 ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.dialog_buttons import dialog_buttons
from ....ui.icons import icon
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.setChecked(agent.enabled if agent else True)
form.addRow("", self.enabled_chk)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -26,7 +26,7 @@ from typing import Dict, List
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton,
QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
)
@@ -35,6 +35,7 @@ from ....core import admin_agents
from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr
from ....state import AppContext
from ....ui.dialog_buttons import confirm
from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog
@@ -189,9 +190,8 @@ class AgentsAdminTab(QWidget):
agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None:
return
if QMessageBox.question(
self, tr("agents_admin.delete_title"),
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
if not confirm(self, tr("agents_admin.delete_title"),
tr("agents_admin.delete_confirm", name=agent.name)):
return
admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh()
+6 -1
View File
@@ -25,13 +25,16 @@ class McpTab(QWidget):
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
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_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
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:
"""Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +47,8 @@ class McpTab(QWidget):
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
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:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -139,11 +139,11 @@ class PricingPanel(QGroupBox):
def _add_pricing_row(self) -> None:
"""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
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name, ok = ask_text(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt"))
name = (name or "").strip()
if not ok or not name:
return
@@ -31,13 +31,16 @@ class SecurityEventsTab(QWidget):
parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all,
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_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"]
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:
"""Đổ danh sách sự kiện vào bảng."""
@@ -50,6 +53,8 @@ class SecurityEventsTab(QWidget):
self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key))
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:
"""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)
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.setToolTip(spec.description)
desc.setToolTip(desc_text)
desc.setObjectName("hint")
desc.setStyleSheet("border: none;")
lay.addWidget(desc)
@@ -32,6 +32,7 @@ from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
from cowork_local.state import AppContext
from cowork_local.ui.dialog_buttons import dialog_buttons
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.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel)
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm"))
self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
ok="schedtask.ai_confirm")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
self.buttons.accepted.connect(self._confirm)
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.theme import current_palette
from cowork_local.ui.dialog_buttons import confirm
from cowork_local.ui.osutil import open_path
# Priority shown as a plain text tag (no colored-emoji squares). Only the
@@ -317,9 +318,8 @@ class KanbanBoardWidget(QWidget):
elif chosen == next_act:
self._create_next_from_output(task)
elif chosen == del_act:
if QMessageBox.question(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", ""))
) == QMessageBox.Yes:
if confirm(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", ""))):
self._service.delete(tid)
self.refresh()
@@ -335,9 +335,8 @@ class KanbanBoardWidget(QWidget):
"""Confirm, then delete every task in ``selected``. Split out of
_bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("schedtask.menu_delete"),
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
if not confirm(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_multi_confirm", n=len(selected))):
return False
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
self._service.bulk_delete(ids)
@@ -381,7 +380,7 @@ class KanbanBoardWidget(QWidget):
nxt["dependency"]["previous_task_id"] = task["task_id"]
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
self._repo.save(nxt)
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.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.osutil import open_path
@@ -53,7 +54,7 @@ class RunHistoryDialog(QDialog):
self.table.itemDoubleClicked.connect(self._open_artifact)
root.addWidget(self.table, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Close)
buttons = dialog_buttons(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept)
root.addWidget(buttons)
+4 -4
View File
@@ -43,10 +43,10 @@ class AboutSettingsWidget(QWidget):
self.app_label.setFont(font)
layout.addWidget(self.app_label)
self.credit_label = QLabel(tr("app.credit"))
self.credit_label.setObjectName("faint")
self.credit_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(self.credit_label)
self.version_label = QLabel(tr("app.version", v=__version__))
self.version_label.setObjectName("faint")
self.version_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(self.version_label)
layout.addStretch(1)
+9 -4
View File
@@ -177,10 +177,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self.help_agent.status_message.connect(self.statusBar().showMessage)
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
# (presentation/settings/about_widget.py). Nó từng là widget thường trực
# ở 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ả
# phiên, cho một thông tin chỉ cần đọc một lần.
# 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ổ).
# 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.
self.statusBar().setSizeGripEnabled(False)
self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
self._restore_sessions()
# 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
@@ -218,6 +218,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
# Keep the floating Help assistant pinned to the bottom-right corner.
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()
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
# padding) — same indent level, same icon size as e.g. Dashboard.
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.addStretch(1)
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.setVisible(False)
head = QVBoxLayout()
head.setContentsMargins(6, 0, 6, 6)
head.setContentsMargins(10, 0, 10, 6)
head.setSpacing(6)
head.addWidget(self.nav_project)
head.addWidget(self.nav_project_btn)
@@ -136,7 +136,7 @@ class NavRailMixin:
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_body = QWidget()
sv = QVBoxLayout(scroll_body)
sv.setContentsMargins(0, 0, 0, 0)
sv.setContentsMargins(6, 0, 6, 0)
sv.setSpacing(0)
sv.addWidget(self.nav, 0)
# RECENTS — the threads of the project named in the picker above, right
@@ -197,9 +197,9 @@ class NavRailMixin:
def _nav_rows(self):
"""(tree, page, sub, label, icon, enabled) for every row, rail order.
Workspace contributes all five of its sub-views — including the two the
project gate currently disables — so the rail never changes shape while
the user is looking at it.
Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ
những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện
chúng ở dạng mờ.
"""
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
for label, sub, ic, on in self.workspace.nav_entries()]
@@ -238,37 +238,28 @@ class NavRailMixin:
tree.clear()
tree.blockSignals(blocked)
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.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
if not enabled:
# 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:
if self._nav_collapsed:
it.setToolTip(0, label)
blocked = tree.blockSignals(True)
tree.addTopLevelItem(it)
tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the
# stretch in between belongs to RECENTS.
#
# The frame, and nothing else. A flat ``+ 8`` here used to leave 6px
# 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.
# Rows plus frame, nothing else: the flat ``+ 8`` this replaces
# 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
# Settings alone — 18/26px for rows that read as one list.
row_h = 0
for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else row_h
tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
# Settings is one more row of the same list, so it gets the rows'
# own height rather than a second set of paddings guessed to match
# it — the only way the three stay evenly spaced when the font (and
# with it ``sizeHintForRow``) is not the one this was tuned on.
# Settings is one more row of the list, so it takes the rows' own
# height instead of paddings guessed to match it.
if row_h and hasattr(self, "_nav_settings_btn"):
self._nav_settings_btn.setFixedHeight(row_h)
if keep:
+8 -1
View File
@@ -13,8 +13,15 @@ from PySide6.QtWidgets import QStyledItemDelegate
# ---- kích thước ---------------------------------------------------------
_NAV_EXPANDED_WIDTH = 232
_NAV_COLLAPSED_WIDTH = 54
_NAV_ROW_INSET = 4
_NAV_ROW_INSET = 8
_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.
# 10 -> 4: đủ để Cài đặt không dính vào nhóm Dashboard/Giám sát, nhưng không
# rộng đến mức trông như hai khu tách rời.
_NAV_SETTINGS_GAP = 4
# 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.
_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
# leaving an empty box and a button that silently does nothing.
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)
if idx >= 0:
self.nav_project.setCurrentIndex(idx)
+1 -1
View File
@@ -106,4 +106,4 @@ class SessionEventsMixin:
"""Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too
self.structure.refresh_project_list() # GraphRAG's project lock list follows too
+7 -1
View File
@@ -35,7 +35,7 @@ class TopBarMixin:
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
from ...i18n import tr
from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
# 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
@@ -71,6 +71,12 @@ class TopBarMixin:
# 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.
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)
self._account_row = self._build_account_row()
+3 -3
View File
@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
)
from ...i18n import tr
from ...ui.dialog_buttons import confirm
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
_DEFAULT_PROJECT_NAME = "Project"
@@ -292,9 +293,8 @@ class ProjectEditingMixin:
project = load_project(pid) if pid else None
if project is None:
return
if QMessageBox.question(
self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
if not confirm(self, tr("workspace.delete"),
tr("workspace.delete_confirm", name=project.name)):
return
delete_project(pid)
self._current_id = ""
+22
View File
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
set "VENV=%APPHOME%\venv"
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 1. Chon trinh thong dich
rem
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
)
if not defined RUNPY (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
echo.
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
if not exist "%VENV%\Scripts\python.exe" (
!RUNPY! -c "import PySide6" >nul 2>&1
if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
echo.
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo.
echo [LOI] Khong tao duoc lien ket thu muc:
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 ("'cowork_local' is a package and cannot be directly executed").
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 [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
echo "!PKGPATH!"
@@ -138,10 +150,20 @@ if defined PYTHONPATH (
set "PYTHONIOENCODING=utf-8"
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 %*
set "RC=%ERRORLEVEL%"
if not "%RC%"=="0" (
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
)
echo.
echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên.
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',
]
# 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:
"""Chấm điểm rủi ro một lệnh shell.
@@ -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
— du liệu van con trong step, chi khong hien tren UI.
* ``_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
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
nhanh code song.
@@ -98,7 +98,7 @@ import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
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 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")
p._step = pstep
orig_getItem = QInputDialog.getItem
orig_getText = QInputDialog.getText
# Panel goi ask_item/ask_text cua ui/dialog_buttons (khong con goi thang ham
# 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}
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()
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 changed_n["n"] == 1
print("CASE_ADD_SUBAGENT_PICKED_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True))
_acts.ask_item = lambda *a, **k: ("", True)
p._add_subagent()
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"
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()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
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
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
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()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
del StepConfigPanel._available_agent_names
QInputDialog.getItem = orig_getItem
QInputDialog.getText = orig_getText
_acts.ask_item = orig_getItem
_acts.ask_text = orig_getText
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
# ---- _edit_subagent (410-428) -----------------------------------------------
@@ -323,7 +329,7 @@ def _fake_get_item(*a, **k):
return ("Renamed", True)
QInputDialog.getItem = staticmethod(_fake_get_item)
_acts.ask_item = _fake_get_item
item0 = p.sub_list.item(0)
p._edit_subagent(item0)
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)
p._edit_subagent(foreign_item)
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")
# ---- _del_subagent (430-437) -------------------------------------------------
@@ -55,6 +55,25 @@ def test_structure_graph_view_builds(ctx):
assert view.qa is not None
def test_refresh_project_list_forwards_to_renderer(ctx):
"""Crash bug: ``presentation/shell/session_events.py::_on_projects_changed``
called ``self.structure._refresh_project_combo()`` — a method that only
ever existed on ``GraphRenderer``, not on ``StructureGraphView`` itself —
so creating/renaming/deleting a project (anywhere in the app) raised
``AttributeError`` and crashed. ``refresh_project_list()`` is the public
forwarding method callers must use instead (matching ``schedule_rescan``/
``set_project``/``prewarm``'s existing forwarding pattern)."""
from cowork_local.presentation.graph.structure_graph_view import StructureGraphView
view = StructureGraphView(ctx)
assert not hasattr(view, "_refresh_project_combo")
combo = view.renderer.project_combo
before = combo.count()
view.refresh_project_list() # must not raise
assert combo.count() == before # re-populated from the same project list, same size
def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
from cowork_local.presentation.graph.graph_renderer import GraphRenderer
+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:
from PySide6.QtWidgets import QInputDialog
ctx = _FakeCtx(tmp_path)
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()
assert panel.table.rowCount() == 1
assert panel.table.item(0, 0).text() == "gpt-test"
+77
View File
@@ -0,0 +1,77 @@
"""DF-007 — construction smoke tests for the two new MS365 cloud dialogs.
Not a full characterization suite (see tests/test_monitoring_tab_container.py
for the convention this follows) — just proves each dialog builds against a
real AppConfig/AppContext without touching the network (Graph calls faked via
monkeypatch)."""
from __future__ import annotations
import copy
import os
os.environ.setdefault("QT_QPA_PLATFORM", "offscreen")
import pytest
QApplication = pytest.importorskip("PySide6.QtWidgets").QApplication
QDialog = pytest.importorskip("PySide6.QtWidgets").QDialog
from cowork_local.config import AppConfig, DEFAULT_CONFIG
from cowork_local.core import ms365_auth
from cowork_local.core import ms365_graph as graph
from cowork_local.ui.cloud_folder_picker_dialog import CloudFolderPickerDialog
from cowork_local.ui.ms365_signin_dialog import Ms365SignInDialog, ensure_signed_in
@pytest.fixture(scope="module")
def qapp():
app = QApplication.instance() or QApplication([])
yield app
@pytest.fixture()
def config(tmp_path):
return AppConfig(data=copy.deepcopy(DEFAULT_CONFIG), path=tmp_path / "config.json")
def test_signin_dialog_constructs(qapp, config) -> None:
dialog = Ms365SignInDialog(config)
assert dialog.windowTitle()
def test_ensure_signed_in_short_circuits_when_already_signed_in(qapp, config, monkeypatch) -> None:
monkeypatch.setattr(ms365_auth, "is_signed_in", lambda cfg: True)
assert ensure_signed_in(None, config) is True
def test_cloud_folder_picker_constructs_and_lists_onedrive_root(qapp, config, monkeypatch) -> None:
monkeypatch.setattr(ms365_auth, "get_access_token", lambda tenant_id, client_id: "fake-token")
monkeypatch.setattr(graph, "list_onedrive_files", lambda token, path="": [
{"name": "Documents", "folder": {}},
{"name": "readme.txt"},
])
dialog = CloudFolderPickerDialog(config)
assert dialog._tree.topLevelItemCount() == 2
source = dialog.cloud_source()
assert source == {"provider": "onedrive", "site_id": "", "site_name": "", "remote_path": ""}
def test_cloud_folder_picker_navigates_into_a_folder(qapp, config, monkeypatch) -> None:
monkeypatch.setattr(ms365_auth, "get_access_token", lambda tenant_id, client_id: "fake-token")
def fake_list(token, path=""):
if path == "":
return [{"name": "Documents", "folder": {}}]
if path == "Documents":
return [{"name": "report.docx"}]
return []
monkeypatch.setattr(graph, "list_onedrive_files", fake_list)
dialog = CloudFolderPickerDialog(config)
folder_item = dialog._tree.topLevelItem(0)
dialog._on_item_activated(folder_item, 0)
assert dialog._current_remote_path() == "Documents"
assert dialog.cloud_source()["remote_path"] == "Documents"
+125
View File
@@ -0,0 +1,125 @@
"""Công tắc "Chặn mạng cho lệnh do agent chạy" phải chặn MỌI đường ra mạng của
agent, không riêng ``run_command``.
Trước đây ``block_network`` chỉ được đọc ở đúng một chỗ —
``infrastructure/filesystem/command_tools.py`` trong ``run_command`` — nên bốn
tool mang ``ToolCapability.NETWORK`` (``fetch_url``, ``jira_search``,
``jira_get_issue``, ``install_package``) vẫn ra internet bình thường trong khi
màn Monitoring báo "Mạng: Bị chặn" và docstring của ``fetch_tools`` tự nhận là
*"Honors the Sandbox Security Layer's Block network policy"*. Người dùng bật
công tắc rồi thấy agent vẫn search web được — đúng triệu chứng được báo.
Hai nhóm bài:
* **hành vi** — bật thì mọi tool NETWORK từ chối TRƯỚC khi chạm mạng, tắt thì
đường cũ giữ nguyên (chặn một chiều là hỏng tính năng);
* **guardrail** — thêm tool mạng mới mà quên chặn thì bài ở đây đỏ ngay.
"""
from __future__ import annotations
from typing import Dict
import pytest
from cowork_local.core.tools import ToolContext
from cowork_local.domain.tools import BUILT_IN_CAPABILITIES, ToolCapability
from cowork_local.infrastructure.filesystem import command_tools, fetch_tools
# tên tool -> (handler, args hợp lệ tối thiểu). Args phải hợp lệ, nếu không bài
# test sẽ đỏ vì lỗi thiếu tham số chứ không vì cổng chặn mạng.
_TOOL_MANG: Dict[str, tuple] = {
"fetch_url": (fetch_tools.fetch_url, {"url": "https://example.com/"}),
"jira_search": (fetch_tools.jira_search, {"jql": "project = ABC"}),
"jira_get_issue": (fetch_tools.jira_get_issue, {"key": "ABC-1"}),
"install_package": (command_tools.install_package, {"package": "requests"}),
}
@pytest.fixture
def cam_ra_mang(monkeypatch):
"""Mọi đường ra mạng thật đều nổ.
Vừa giữ cho bộ test không chạm internet, vừa làm lộ tool nào lọt qua cổng
chặn: nó sẽ đỏ ngay tại lời gọi mạng thay vì im lặng đi ra ngoài.
"""
def no_ra_mang(*args, **kwargs):
raise AssertionError("tool đã chạm mạng dù 'Chặn mạng' đang bật")
from cowork_local.core import deps, jira_tool, link_fetch
monkeypatch.setattr(link_fetch, "fetch_link_preview", no_ra_mang)
monkeypatch.setattr(jira_tool, "search", no_ra_mang)
monkeypatch.setattr(jira_tool, "get_issue", no_ra_mang)
monkeypatch.setattr(jira_tool, "get_issue_by_url", no_ra_mang)
monkeypatch.setattr(deps, "pip_install", no_ra_mang)
# ---- hành vi: bật công tắc thì mọi tool mạng đều bị chặn -----------------
@pytest.mark.parametrize("ten", sorted(_TOOL_MANG))
def test_bat_chan_mang_thi_tool_tu_choi_truoc_khi_cham_mang(tmp_path, cam_ra_mang, ten):
"""Đây là chính triệu chứng người dùng báo: bật rồi mà vẫn ra được web."""
handler, args = _TOOL_MANG[ten]
ctx = ToolContext(tmp_path, block_network=True)
ket_qua = handler(ctx, args)
assert ket_qua["ok"] is False, f"{ten} vẫn chạy khi đang chặn mạng"
assert "Sandbox Security Layer" in ket_qua["output"], ket_qua["output"]
def test_allow_url_fetch_khong_lach_duoc_chan_mang(tmp_path, cam_ra_mang):
"""Hai công tắc vẫn độc lập, nhưng "Chặn mạng" là cái mạnh hơn: bật nó thì
"Cho phép agent lấy dữ liệu từ URL" không mở lại đường được."""
ctx = ToolContext(tmp_path, block_network=True, allow_url_fetch=True)
ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"})
assert ket_qua["ok"] is False
# ---- hành vi: tắt công tắc thì đường cũ giữ nguyên -----------------------
def test_tat_chan_mang_thi_fetch_url_van_doc_duoc(tmp_path, monkeypatch):
"""Chặn một chiều là hỏng tính năng — cổng phải mở lại được."""
from cowork_local.core import link_fetch
monkeypatch.setattr(link_fetch, "fetch_link_preview",
lambda url: f"nội dung của {url}")
ctx = ToolContext(tmp_path, block_network=False)
ket_qua = fetch_tools.fetch_url(ctx, {"url": "https://example.com/"})
assert ket_qua["ok"] is True
assert "example.com" in ket_qua["output"]
def test_tat_chan_mang_thi_install_package_van_chay(tmp_path, monkeypatch):
from cowork_local.core import deps
da_goi = []
def gia_lap_pip(package, **kwargs):
da_goi.append(package)
return True, "ok"
monkeypatch.setattr(deps, "pip_install", gia_lap_pip)
ctx = ToolContext(tmp_path, block_network=False)
ket_qua = command_tools.install_package(ctx, {"package": "requests"})
assert da_goi == ["requests"]
assert ket_qua["ok"] is True
# ---- guardrail: danh sách tool mạng không được lệch ----------------------
def test_moi_tool_mang_deu_co_bai_o_day():
"""``BUILT_IN_CAPABILITIES`` là nơi duy nhất khai báo tool nào chạm mạng.
Thêm một tool NETWORK mới mà quên chặn thì bài này đỏ ngay."""
tag_mang = {ten for ten, cap in BUILT_IN_CAPABILITIES.items()
if cap & ToolCapability.NETWORK}
assert tag_mang == set(_TOOL_MANG), (
"danh sách tool mạng đã đổi — chặn tool mới ở cổng block_network "
"rồi bổ sung vào _TOOL_MANG")
+25 -16
View File
@@ -1,11 +1,12 @@
"""Mục "Giới thiệu" trong Cài đặt, và việc gỡ dòng ghi công khỏi thanh trạng thái.
"""Mục "Giới thiệu" trong Cài đặt, và dòng phiên bản ở góc dưới phải.
Dòng "Made by QuanDH14" từng là widget thường trực ở 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ả phiên, cho một thông tin chỉ cần đọc một lần.
Nó chuyển vào Cài đặt ▸ Giới thiệu — vẫn tra được, không còn đứng thường trực.
Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải, rồi chuyển
hẳn vào Cài đặt ▸ Giới thiệu. Nay chỗ đó mang MỘT thông tin khác: số phiên bản —
người dùng muốn tra được ngay mà không phải mở Cài đặt.
Hai đầu của việc chuyển này phải được chốt cùng nhau: chỉ thêm mục mới mà quên gỡ
chỗ cũ thì thành hiện hai lần; chỉ gỡ mà quên thêm thì mất hẳn.
Điểm phải chốt là **một nguồn duy nhất**: tiêu đề cửa sổ, tab Giới thiệu và góc
dưới phải đều đọc ``cowork_local.__version__``. Ghi cứng con số ở một trong ba
chỗ thì lần nâng phiên bản sau sẽ để lại một chỗ nói sai.
"""
from __future__ import annotations
@@ -41,11 +42,13 @@ def test_muc_gioi_thieu_dung_cuoi(dialog):
assert last == tr("settings.group.about")
def test_muc_gioi_thieu_hien_dong_ghi_cong(dialog):
def test_muc_gioi_thieu_hien_dong_phien_ban(dialog):
from cowork_local import __version__
from cowork_local.i18n import tr
assert dialog._about_page.credit_label.text() == tr("app.credit")
assert "QuanDH14" in dialog._about_page.credit_label.text()
text = dialog._about_page.version_label.text()
assert text == tr("app.version", v=__version__)
assert __version__ in text
def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
@@ -56,10 +59,10 @@ def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog):
assert __version__ in text
# ---- đầu CŨ: không còn ở thanh trạng thái --------------------------------
# ---- góc dưới phải: đúng một dòng, và là dòng phiên bản ------------------
def test_khong_con_dong_ghi_cong_trong_ma_nguon_cua_so_chinh():
"""Chốt cả hai đầu: cửa sổ chính không được dựng lại widget ghi công.
def test_goc_duoi_phai_hien_phien_ban_tu_cung_mot_nguon():
"""Thanh trạng thái mang ĐÚNG một widget thường trực, là dòng phiên bản.
Kiểm trên mã nguồn thay vì dựng ``MainWindow`` — dựng cửa sổ thật mất ~35
giây cho một khẳng định mà đọc file trả lời được ngay.
@@ -69,7 +72,13 @@ def test_khong_con_dong_ghi_cong_trong_ma_nguon_cua_so_chinh():
src = (Path(__file__).resolve().parents[2]
/ "presentation" / "shell" / "main_window.py").read_text(encoding="utf-8")
assert "_credit" not in src, "vẫn còn widget ghi công ở cửa sổ chính"
assert "addPermanentWidget" not in src, (
"thanh trạng thái lại có widget thường trực — nếu là thứ khác thì sửa "
"khẳng định này, nhưng phải cân nhắc: nó chiếm một góc trên mọi màn hình")
assert "_credit" not in src, "widget ghi công tác giả đã chuyển vào Cài đặt"
assert src.count("addPermanentWidget") == 1, (
"góc dưới phải chỉ được mang MỘT widget thường trực — nó chiếm một góc "
"màn hình trên MỌI màn hình, suốt cả phiên làm việc")
assert 'tr("app.version", v=__version__)' in src, (
"dòng phiên bản phải đọc __version__ như tiêu đề cửa sổ và tab Giới "
"thiệu; ghi cứng con số ở đây thì lần nâng phiên bản sau sẽ nói sai")
assert "setSizeGripEnabled(False)" in src, (
"size grip vẽ một vệt ngay bên phải dòng phiên bản — góc đó chỉ được "
"mang mỗi con số; cửa sổ vẫn kéo giãn được từ các cạnh")
+5 -1
View File
@@ -34,7 +34,7 @@ MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào
#: vào đây phải kèm lý do — đây là chỗ dễ dùng để lặng lẽ bỏ qua việc dịch.
KHOA_KHONG_CAN_DICH = {
# thương hiệu / tên sản phẩm
"app.logo", "app.credit", "login.header",
"app.logo", "app.version", "login.header",
"app.tab.code", "app.tab.cowork", "app.tab.structure",
"code.title", "code.onedrive_badge", "code.onedrive_btn",
"cowork.title", "sidebar.filter.code", "sidebar.filter.cowork",
@@ -44,11 +44,15 @@ KHOA_KHONG_CAN_DICH = {
"settings.group.teams", "settings.history_onedrive",
"settings.ms365_connector.onedrive", "settings.ms365_connector.outlook",
"settings.ms365_connector.sharepoint", "settings.ms365_connector.teams",
# tên nhóm catalog = danh sách tên sản phẩm, giống hệt ở cả ba ngôn ngữ
"connectors.cat_cad", "connectors.cat_cae", "connectors.cat_ms365",
"schedtask.stepexec.co4e", "schedtask.stepexec.cowork",
"schedtask.type.co4e_code", "schedtask.type.cowork",
"monitoring.tab_mcp", "ext.mode_rest", "schedtask.f_cron",
"help_agent.badge", "help_agent.title", # dùng nguyên dạng trong cả câu tiếng Nhật
"help_agent.default_user", # đứng thay cho TÊN người dùng
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — bản tiếng Nhật của chính Qt cũng dùng "OK"
"dialog.ok",
# viết tắt / ký hiệu / thuần định dạng
"accounts.ai_search_btn", "monitoring.ai_filter_btn", "monitoring.overview_res_cpu",
"monitoring.na", "schedtask.add_link_label", "settings.teams_webhook",
+354
View File
@@ -0,0 +1,354 @@
"""Chữ trên giao diện phải đi qua ``tr()`` — kể cả chữ do CHÍNH Qt vẽ.
Bộ ``test_i18n_khong_con_chu_cu.py`` bắt lỗi "có dịch nhưng không ai áp lại".
Nó không bắt được lỗi người dùng báo tháng 9/2026: "đang tiếng Nhật mà nút Save,
Cancel và phần mật khẩu sandbox vẫn tiếng Anh". Lý do là hai lỗ khác hẳn:
* **Chuỗi hardcode.** ``QPushButton("Unlock")`` không hỏi ``tr()`` bao giờ, nên
phép đo "đổi ngôn ngữ rồi tìm chỗ không mang mốc" thấy nó đứng yên ở cả hai
lần chụp và coi là bình thường.
* **Nhãn nút của chính Qt.** ``QDialogButtonBox(Save | Cancel)`` và
``QMessageBox.question`` lấy chữ từ bảng dịch của Qt. Ứng dụng không cài
``QTranslator`` nào nên bảng đó luôn rơi về tiếng Anh — không dòng mã nào của
dự án sai, mà màn hình vẫn sai.
Ba cổng dưới đây canh cả hai lỗ, và canh ở mức CẤU TRÚC (không ai được dựng lại
kiểu cũ) chứ không chỉ sửa xong từng chỗ.
"""
from __future__ import annotations
import ast
import pathlib
import pytest
REPO = pathlib.Path(__file__).resolve().parents[2]
#: Chỉ hai tầng này vẽ giao diện.
THU_MUC_UI = ("ui", "presentation")
#: Nơi DUY NHẤT được phép chạm vào nút chuẩn của Qt — chính chỗ gán nhãn lại.
NOI_BOC_QT = "ui/dialog_buttons.py"
def _file_ui():
"""Mọi file .py thuộc tầng giao diện, kèm cây AST đã phân tích."""
for thu_muc in THU_MUC_UI:
for path in sorted((REPO / thu_muc).rglob("*.py")):
rel = path.relative_to(REPO).as_posix()
yield rel, ast.parse(path.read_text(encoding="utf-8"))
def _ten_ham(func) -> str:
if isinstance(func, ast.Name):
return func.id
if isinstance(func, ast.Attribute):
return func.attr
return ""
# ---- cấu trúc: không ai được dùng lại nhãn mặc định của Qt ----------------
def test_khong_dung_nut_chuan_qdialogbuttonbox_truc_tiep():
"""``QDialogButtonBox(Save | Cancel)`` phải đi qua ``dialog_buttons()``.
Dựng thẳng thì Qt tự vẽ chữ, và chữ đó là tiếng Anh ở cả ba ngôn ngữ.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "QDialogButtonBox"
and node.args):
# Không tham số = hộp rỗng rồi tự addButton(tr(...)) — vẫn đúng.
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"dựng QDialogButtonBox với nút chuẩn thì nhãn do Qt vẽ và luôn là tiếng "
"Anh — dùng ui.dialog_buttons.dialog_buttons() thay vào:\n "
+ "\n ".join(vi_pham))
def test_khong_goi_qinputdialog_tinh_truc_tiep():
"""Hộp nhập liệu phải đi qua ``ask_text`` / ``ask_multiline`` / ``ask_item``.
``QInputDialog.getText`` và họ hàng tự dựng hộp thoại bên trong nên không ai
chạm được vào nút của nó: "Cancel" đứng nguyên tiếng Anh ở cả ja lẫn vi.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QInputDialog"
and node.func.attr.startswith("get")):
vi_pham.append(f"{rel}:{node.lineno} QInputDialog.{node.func.attr}")
assert vi_pham == [], (
"hàm tĩnh QInputDialog hiện nút Cancel tiếng Anh ở mọi ngôn ngữ — dùng "
"ask_text / ask_multiline / ask_item của ui.dialog_buttons:\n "
+ "\n ".join(vi_pham))
def test_khong_goi_qmessagebox_question_truc_tiep():
"""Hộp hỏi Có/Không phải đi qua ``confirm()``.
``QMessageBox.question`` hiện "&Yes" / "&No" tiếng Anh ở mọi ngôn ngữ.
"""
vi_pham = []
for rel, tree in _file_ui():
if rel == NOI_BOC_QT:
continue
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and isinstance(node.func, ast.Attribute)
and node.func.attr == "question"
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QMessageBox"):
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"QMessageBox.question hiện nút Yes/No tiếng Anh ở mọi ngôn ngữ — dùng "
"ui.dialog_buttons.confirm() thay vào:\n " + "\n ".join(vi_pham))
# ---- dữ liệu: không chuỗi hiển thị nào được viết thẳng vào widget ----------
#: Đối số thứ N của các hàm dựng widget này là NHÃN hiển thị.
CTOR_NHAN = {
"QLabel": 0, "QPushButton": 0, "QGroupBox": 0, "QCheckBox": 0,
"QRadioButton": 0, "QToolButton": 0, "QAction": 0, "QMenu": 0,
"QListWidgetItem": 0, "QTreeWidgetItem": 0, "QTableWidgetItem": 0,
"IconLabel": 1, "ToggleSwitch": 0,
}
#: Đối số thứ N của các setter này là chuỗi hiển thị.
SETTER_NHAN = {
"setText": 0, "setToolTip": 0, "setPlaceholderText": 0, "setWindowTitle": 0,
"setTitle": 0, "setStatusTip": 0, "setWhatsThis": 0, "setLabelText": 0,
"setInformativeText": 0, "setAccessibleName": 0, "addItem": 0,
"insertItem": 1, "setItemText": 1, "addAction": 0, "addTab": 1,
"insertTab": 2, "setTabText": 1, "setTabToolTip": 1, "setHeaderLabel": 0,
}
#: Mọi đối số chuỗi đều là nhãn.
SETTER_NHAN_TAT_CA = {
"setHorizontalHeaderLabels", "setHeaderLabels", "setVerticalHeaderLabels",
}
#: ``QMessageBox.<loai>(parent, title, body)`` — đối số 1 và 2 là chữ hiển thị.
MSGBOX_LOAI = {"information", "warning", "critical", "about"}
#: Chuỗi cố ý viết thẳng, kèm lý do. Thêm mục ở đây là một quyết định, không
#: phải cách làm cổng xanh lại — chữ người dùng ĐỌC ĐƯỢC thì phải dịch.
CHO_PHEP_HARDCODE = {
# ký hiệu đơn vị và tiền tố phiên bản: giống nhau ở cả ba ngôn ngữ
("presentation/monitoring/tabs/overview_tab.py", "/s"),
("presentation/settings/about_widget.py", " v"),
("presentation/settings/routing_settings_widget.py", " s"),
("presentation/settings/routing_settings_widget.py", " h"),
("presentation/shell/main_window.py", " v"),
("ui/task_editor_dialog.py", " s"),
}
def _chuoi(node) -> str | None:
"""Chữ của một literal chuỗi. Bỏ qua f-string đã có ``tr()`` bên trong."""
if isinstance(node, ast.Constant) and isinstance(node.value, str):
return node.value
if isinstance(node, ast.JoinedStr):
if any(isinstance(n, ast.Call) and _ten_ham(n.func) == "tr"
for n in ast.walk(node)):
return None
phan = [v.value for v in node.values
if isinstance(v, ast.Constant) and isinstance(v.value, str)]
return "".join(phan) if phan else None
return None
def _la_chu_nguoi_doc(s: str) -> bool:
"""Chuỗi có phải chữ người dùng ĐỌC, chứ không phải mã/ký hiệu/định dạng.
Mốc "ít nhất ba chữ cái" loại được đơn vị (``/s``, `` h``) và tiền tố phiên
bản (`` v``) mà vẫn giữ lại mọi nhãn thật — nhãn ngắn nhất trên màn này là
"Unlock".
"""
import re
if len(s.strip()) < 2 or re.fullmatch(r"[\W\d_]+", s):
return False
if re.fullmatch(r"[a-z][a-zA-Z0-9]*|[a-z0-9_.]+", s):
return False # objectName / khoá dạng định danh
if s.startswith(("http://", "https://", "#", "<")):
return False
return len(re.findall(r"[A-Za-zÀ-ỹ぀-ヿ一-鿿]", s)) >= 3
def test_khong_con_chuoi_hien_thi_viet_thang_vao_widget():
"""Không nhãn/tooltip/placeholder nào được viết thẳng bằng một ngôn ngữ.
Đây là nửa còn lại của lỗi tháng 9/2026: nhóm mật khẩu Sandbox Security
dựng bằng ``QPushButton("Unlock")`` và ``IconLabel(..., "Locked ...")``, nên
đổi sang tiếng Nhật thì cả cụm vẫn nguyên tiếng Anh.
"""
vi_pham = []
for rel, tree in _file_ui():
for node in ast.walk(tree):
if not isinstance(node, ast.Call):
continue
fn, args = _ten_ham(node.func), node.args
def bao(i: int) -> None:
if i >= len(args):
return
s = _chuoi(args[i])
if s and _la_chu_nguoi_doc(s) and (rel, s) not in CHO_PHEP_HARDCODE:
vi_pham.append(f"{rel}:{args[i].lineno} {fn}({s!r})")
if fn in CTOR_NHAN:
bao(CTOR_NHAN[fn])
if fn in SETTER_NHAN:
bao(SETTER_NHAN[fn])
if fn in SETTER_NHAN_TAT_CA:
for i in range(len(args)):
bao(i)
if (fn in MSGBOX_LOAI and isinstance(node.func, ast.Attribute)
and isinstance(node.func.value, ast.Name)
and node.func.value.id == "QMessageBox"):
bao(1)
bao(2)
assert vi_pham == [], (
"chữ hiển thị viết thẳng, không đi qua tr() nên không đổi theo ngôn "
"ngữ:\n " + "\n ".join(sorted(vi_pham)))
def test_moi_tool_co_mo_ta_hien_thi_rieng():
"""Mỗi tool trong ``TOOL_SPECS`` phải có khoá mô tả để hiển thị.
Thẻ tool ở Giám sát ▸ Công cụ từng hiện thẳng ``spec.description`` — chuỗi
gửi cho MÔ HÌNH trong schema function-calling, nên luôn tiếng Anh. Thiếu
khoá thì ``tr()`` trả về chính khoá, và người dùng đọc được
``tools_admin.desc.<tên>`` ngay trên thẻ.
"""
from cowork_local.core.tools import TOOL_SPECS
from cowork_local.i18n import STRINGS
thieu = [s.name for s in TOOL_SPECS
if f"tools_admin.desc.{s.name}" not in STRINGS]
assert thieu == [], (
"tool chưa có khoá mô tả hiển thị (thêm tools_admin.desc.<tên> vào "
f"i18n/libreoffice_view.py): {thieu}")
def test_add_section_nhan_khoa_chu_khong_nhan_chuoi_da_dich():
"""``_add_section`` tự dựng nhãn mục, nên phải nhận KHOÁ.
Truyền ``tr(...)`` vào đây hỏng ÂM THẦM: khoá thiếu thì ``tr()`` trả về
chính chuỗi đưa vào, nên tiêu đề hiện đúng ở ngôn ngữ lúc dựng panel rồi
đứng nguyên mãi. Đúng lỗi người dùng báo: mở Co4E ▸ Step config ở tiếng
Nhật thì đổi sang tiếng Anh vẫn còn tiếng Nhật.
"""
vi_pham = []
for rel, tree in _file_ui():
for node in ast.walk(tree):
if (isinstance(node, ast.Call) and _ten_ham(node.func) == "_add_section"
and len(node.args) >= 2
and isinstance(node.args[1], ast.Call)
and _ten_ham(node.args[1].func) == "tr"):
vi_pham.append(f"{rel}:{node.lineno}")
assert vi_pham == [], (
"_add_section nhận khoá dịch, không nhận tr(...) — truyền chuỗi đã dịch "
"thì tiêu đề mục không bao giờ đổi lại:\n " + "\n ".join(vi_pham))
# ---- lúc chạy: nhãn nút thật sự đổi theo ngôn ngữ -------------------------
def test_tieu_de_muc_step_config_doi_theo_ngon_ngu(qapp):
"""Bốn tiêu đề mục của Step config phải đổi theo ngôn ngữ.
Panel này dựng LƯỜI (chỉ khi người dùng chọn một bước trên canvas Co4E) nên
nó không nằm trong cây widget mà ``test_i18n_khong_con_chu_cu`` quét — phải
dựng thẳng ở đây thì mới canh được.
"""
pytest.importorskip("PySide6")
from cowork_local import i18n
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
cu = i18n.get_language()
try:
i18n.set_language("ja")
panel = StepConfigPanel() # dựng TRONG lúc đang tiếng Nhật
nhat = [h.text() for h in panel.findChildren(_SectionHeader)]
assert nhat, "không tìm thấy tiêu đề mục nào"
i18n.set_language("en")
anh = [h.text() for h in panel.findChildren(_SectionHeader)]
assert all(a != n for a, n in zip(anh, nhat)), (
f"tiêu đề mục giữ nguyên ngôn ngữ lúc dựng panel: {anh}")
assert anh == [f"▶ {i18n.tr(k)}" for k in
("co4e.tab_basic", "co4e.tab_model_perm",
"co4e.tab_skills_files", "co4e.f_subagents")]
finally:
i18n.set_language(cu)
def test_doi_ngon_ngu_khong_lam_mat_trang_thai_gap_mo(qapp):
"""Mục đang MỞ vẫn phải mang dấu ▼ sau khi đổi ngôn ngữ.
Đây là lý do nhãn này dùng ``bind_dynamic`` chứ không ``bind_text``: bind
cứng một khoá sẽ ghi đè cả dấu gập/mở, trả mục đang mở về ▶ trong khi phần
thân của nó vẫn đang hiện.
"""
pytest.importorskip("PySide6")
from cowork_local import i18n
from cowork_local.presentation.co4e.node_property_panel import StepConfigPanel
from cowork_local.presentation.co4e.step_config_section import _SectionHeader
cu = i18n.get_language()
try:
i18n.set_language("en")
panel = StepConfigPanel()
header = panel.findChildren(_SectionHeader)[0]
header.clicked.emit() # mở mục đầu tiên
assert header.text().startswith("▼")
i18n.set_language("ja")
assert header.text().startswith("▼"), (
f"đổi ngôn ngữ làm mất trạng thái đang mở: {header.text()!r}")
finally:
i18n.set_language(cu)
def test_nhan_nut_hop_thoai_doi_theo_ngon_ngu(qapp):
"""Nút Save/Cancel mang chữ của dự án ở cả ba ngôn ngữ.
Chốt luôn cái bẫy vòng đời: ràng buộc giữ widget bằng ``weakref``, mà nút
chuẩn do C++ dựng nên wrapper Python của nó có thể bị thu hồi. Hộp thoại ở
đây thả hết tham chiếu cục bộ, đúng như mọi call site thật.
"""
pytest.importorskip("PySide6")
from PySide6.QtWidgets import QDialog, QDialogButtonBox, QVBoxLayout
from cowork_local import i18n
from cowork_local.ui.dialog_buttons import dialog_buttons
dlg = QDialog()
QVBoxLayout(dlg).addWidget(
dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel))
hop = dlg.findChild(QDialogButtonBox)
cu = i18n.get_language()
try:
for lang in ("en", "ja", "vi"):
i18n.set_language(lang)
mong_doi = [i18n.STRINGS["dialog.save"][lang],
i18n.STRINGS["dialog.cancel"][lang]]
assert [b.text() for b in hop.buttons()] == mong_doi, (
f"nhãn nút không theo ngôn ngữ {lang}")
finally:
i18n.set_language(cu)
dlg.close()
+141
View File
@@ -0,0 +1,141 @@
"""Cổng project: Cowork và GraphRAG chỉ hiện khi đã chọn một project cụ thể.
Cổng có hai mặt và trước đây chỉ mặt thứ nhất làm đúng:
* **Sub-tab trong màn Workspace** — ``_update_tab_visibility`` vốn đã ẩn/hiện
đúng. Chỗ hỏng nằm ở ``refresh()``: nó mặc định ``row_to_select = 0`` nên lúc
mở app (chưa ai bấm gì) danh sách tự chọn hộ project đầu tiên, mở cổng cho một
project người dùng chưa hề chọn.
* **Hàng trên menu trái** — ``NavRailMixin._rebuild_nav`` từng dựng hàng ở dạng
mờ kèm tooltip thay vì bỏ đi ("shown instead of hidden"), nên người dùng vẫn
thấy Cowork/GraphRAG trên menu dù cổng đang đóng.
Các bài dưới đây chốt cả hai mặt, ở cả ba trạng thái: chưa chọn → ẩn, chọn rồi →
hiện, bỏ chọn → ẩn lại.
"""
from __future__ import annotations
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
@pytest.fixture
def win(qapp, tmp_path):
"""MainWindow thật — cần cả cửa sổ vì phải kiểm cả menu trái.
Đọc project từ ``~/.cowork_local`` như bản cài thật (``core/projects.py``
gắn ``PROJECTS_DIR`` vào đó) nên các bài này KHÔNG tạo/xoá project nào.
Bài nào cần cổng MỞ thì gọi thẳng ``_update_tab_visibility(True)`` thay vì
tạo project trên đĩa của người chạy test.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
config_path = tmp_path / "config.json"
build_config(config_path)
window = MainWindow(build_context(config_path))
yield window
window.close()
def _cong(ws):
"""Hai sub-tab nằm sau cổng project, bỏ qua bản dựng không có chúng."""
return [(ten, idx) for ten, idx in
(("Cowork", ws._cowork_tab_idx), ("GraphRAG", ws._graphrag_tab_idx))
if idx >= 0]
def _hang_menu(win):
"""Nhãn của mọi hàng đang có trên cột menu trái."""
return [win.nav.topLevelItem(i).text(0) for i in range(win.nav.topLevelItemCount())]
# ---- mặt 1: không tự chọn hộ project ------------------------------------
def test_mo_app_len_chua_chon_thi_khong_tu_chon_ho(win):
"""Đây là nguyên nhân gốc: ``refresh()`` từng mặc định chọn dòng 0."""
ws = win.workspace
assert ws._current_id == ""
assert ws.project_list.currentRow() == -1
def test_chua_chon_project_thi_hai_sub_tab_deu_an(win):
ws = win.workspace
for ten, idx in _cong(ws):
assert ws.tabs.isTabVisible(idx) is False, f"{ten} hiện khi chưa chọn project"
assert ws.subtab_available(idx) is False, f"{ten} vẫn mở cổng"
def test_chua_chon_project_thi_dung_o_tab_project(win):
"""Ẩn hai tab kia mà lại đứng ở một tab đã ẩn thì màn hình trống trơn."""
ws = win.workspace
assert ws.current_subtab() == ws._project_tab_idx
def test_refresh_giu_nguyen_project_dang_chon(win):
"""Sửa cổng không được làm mất lựa chọn hiện có: ``keep`` vẫn phải thắng."""
ws = win.workspace
if ws.project_list.count() == 0:
pytest.skip("máy chạy test chưa có project nào để chọn")
ws.project_list.setCurrentRow(0)
dang_chon = ws._current_id
ws.refresh()
assert ws._current_id == dang_chon
assert ws.project_list.currentRow() >= 0
# ---- mặt 2: menu trái bỏ hẳn hàng, không hiện dạng mờ -------------------
def test_chua_chon_project_thi_menu_trai_khong_co_hai_hang(win):
"""Đây là thứ người dùng nhìn thấy — trước đây hai hàng vẫn nằm đó, chỉ mờ."""
nhan = _hang_menu(win)
assert "Cowork" not in nhan, f"Cowork vẫn trên menu: {nhan}"
assert "GraphRAG" not in nhan, f"GraphRAG vẫn trên menu: {nhan}"
def test_mo_cong_thi_hai_hang_quay_lai_menu_trai(win):
"""Bỏ hàng phải đảo ngược được, nếu không thì chọn project xong vẫn kẹt."""
ws = win.workspace
ws._current_id = "gia-lap"
ws._update_tab_visibility(True)
nhan = _hang_menu(win)
assert "Cowork" in nhan, f"Cowork không quay lại: {nhan}"
assert "GraphRAG" in nhan, f"GraphRAG không quay lại: {nhan}"
def test_mo_cong_thi_hai_sub_tab_cung_hien_lai(win):
ws = win.workspace
ws._current_id = "gia-lap"
ws._update_tab_visibility(True)
for ten, idx in _cong(ws):
assert ws.tabs.isTabVisible(idx) is True, f"{ten} vẫn ẩn khi cổng đã mở"
def test_dong_cong_lai_thi_hai_hang_bien_mat(win):
"""Cổng phải đóng lại được, không chỉ mở một chiều."""
ws = win.workspace
ws._current_id = "gia-lap"
ws._update_tab_visibility(True)
ws._current_id = ""
ws._update_tab_visibility(False)
nhan = _hang_menu(win)
assert "Cowork" not in nhan and "GraphRAG" not in nhan, nhan
def test_cac_hang_khac_khong_bi_anh_huong(win):
"""Chỉ hai hàng sau cổng bị bỏ — phần còn lại của menu giữ nguyên."""
nhan = _hang_menu(win)
for bat_buoc in ("Project", "Co4E"):
assert bat_buoc in nhan, f"{bat_buoc} biến mất khỏi menu: {nhan}"
+62 -197
View File
@@ -1,23 +1,21 @@
"""Sandbox Security unlock — chốt các đường KHÔNG được mở khoá (SEC-20260907-01).
"""Sandbox Security Layer: bốn công tắc luôn sửa được, không còn khoá mật khẩu.
``DEFAULT_CONFIG`` ship ``agent_security.sandbox_pw = ""`` kể từ commit
``3827552 fix(security): remove shared unlock defaults``, và cấu hình đưa tới
dialog LUÔN được deep-merge với defaults đó
(``infrastructure/config/json_config_repository.py``). Nghĩa là trên mọi bản cài
không đặt ``COWORK_SANDBOX_PASSWORD``, mật khẩu đã lưu là chuỗi rỗng — và phép so
sánh ``pw == self._sandbox_pw`` nhận luôn ô nhập trống.
Trước đây nhóm này bị khoá: bốn công tắc dựng ra ở trạng thái ``setEnabled(False)``
và chỉ mở khi nhập đúng mật khẩu qua ``_sandbox_unlock()``. Bộ bài cũ ở file này
(SEC-20260907-01) chốt các đường KHÔNG được mở khoá — chúng mất đối tượng kiểm khi
tính năng khoá bị bỏ theo yêu cầu, nên được thay bằng các bài dưới đây.
Ba nhóm bài ở đây:
Docstring của ``_sandbox_unlock()`` cũ đã tự nói rõ nó là gì: *"khoá phía giao diện
để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG phải cơ chế bảo mật thật"*. Rào thật
nằm ở tầng sandbox lúc chạy lệnh, không ở hộp thoại Cài đặt.
* **đường tấn công** — chốt đúng lỗ trên;
* **đường đi đúng** — bản vá không được phá, kể cả với mật khẩu có dấu;
* **chặn cả lớp lỗi** — commit ``3827552`` sửa ``config.py`` nhưng bỏ sót bản sao
thứ hai của literal trong ``ui/settings_dialog.py``. Bài cuối quét chéo mọi thư
mục nguồn để lần sau không sót kiểu đó nữa.
Hai nhóm bài:
* **hành vi mới** — mở hộp thoại là bật/tắt được ngay, không qua bước nào;
* **guardrail** — quét mã nguồn để lần sau không ai lặng lẽ khoá lại.
"""
from __future__ import annotations
import re
from pathlib import Path
import pytest
@@ -27,209 +25,76 @@ import pytest
from .test_settings_dialog_dac_ta import _Ctx
@pytest.fixture
def shown(monkeypatch):
"""Ghi lại mọi QMessageBox thay vì bật modal thật (modal sẽ treo test).
Trả về list các ``(loại, tiêu_đề, nội_dung)`` — cần thiết để phân biệt
"chưa cấu hình mật khẩu" với "sai mật khẩu"; nếu chỉ nuốt hộp thoại đi thì
hai nhánh gộp lại làm một mà test vẫn xanh.
"""
from PySide6.QtWidgets import QMessageBox
calls: list[tuple[str, str, str]] = []
def _record(kind):
def _fn(_parent, title, text, *a, **k):
calls.append((kind, title, text))
return staticmethod(_fn)
monkeypatch.setattr(QMessageBox, "warning", _record("warning"))
monkeypatch.setattr(QMessageBox, "information", _record("information"))
return calls
def _dialog(stored_pw: str):
"""SettingsDialog với ``sandbox_pw`` đúng như bản cài thật: key CÓ mặt."""
def _dialog():
"""SettingsDialog dựng đúng như bản cài thật."""
from cowork_local.ui.settings_dialog import SettingsDialog
ctx = _Ctx()
ctx.config.data["agent_security"]["sandbox_pw"] = stored_pw
return SettingsDialog(ctx)
return SettingsDialog(_Ctx())
# ---- đường tấn công ------------------------------------------------------
def test_o_trong_khong_mo_duoc_khoa(qapp, shown):
"""Chưa đặt mật khẩu (sandbox_pw == "") thì ô nhập trống KHÔNG được mở khoá."""
dlg = _dialog("")
dlg.sandbox_pw_edit.setText("")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
_CONG_TAC = ("sandbox_confirm", "sandbox_block_network", "sec_enabled", "ai_check")
def test_go_bua_khi_chua_dat_mat_khau_cung_khong_mo_duoc(qapp, shown):
"""Mật khẩu lưu rỗng thì KHÔNG chuỗi nào mở được, kể cả chuỗi khác rỗng."""
dlg = _dialog("")
dlg.sandbox_pw_edit.setText("bat ky")
# ---- hành vi mới: sửa được ngay, không cần mật khẩu ----------------------
dlg._sandbox_unlock()
@pytest.mark.parametrize("ten", _CONG_TAC)
def test_cong_tac_sua_duoc_ngay_khi_mo_hop_thoai(qapp, ten):
"""Đây là chính yêu cầu: không còn bước nhập mật khẩu nào chắn ở giữa."""
dlg = _dialog()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
assert getattr(dlg, ten).isEnabled() is True, f"{ten} vẫn bị khoá"
def test_mat_khau_sai_khong_mo_duoc(qapp, shown):
"""Đã đặt mật khẩu thì gõ sai vẫn khoá."""
dlg = _dialog("K7MNP2QRSTVW")
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVX")
@pytest.mark.parametrize("ten", _CONG_TAC)
def test_bat_tat_duoc_va_luu_dung_gia_tri(qapp, ten):
"""Bật/tắt phải ăn vào widget — khoá cũ chặn đúng ở bước này."""
dlg = _dialog()
w = getattr(dlg, ten)
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
truoc = w.isChecked()
w.setChecked(not truoc)
assert w.isChecked() is (not truoc)
w.setChecked(truoc)
assert w.isChecked() is truoc
# ---- thông báo phải phân biệt được hai tình huống -------------------------
def test_khong_con_widget_mat_khau_nao(qapp):
"""Ô nhập, nút Mở khoá và nhãn "Đang khoá" phải biến mất khỏi hộp thoại."""
dlg = _dialog()
def test_chua_cau_hinh_bao_khac_voi_sai_mat_khau(qapp, shown):
"""Hai nhánh phải nói hai chuyện khác nhau.
Người chưa từng đặt mật khẩu mà nhận "Password incorrect" sẽ gõ lại mãi một
thứ không tồn tại. Không có bài này thì gộp hai nhánh về một thông báo chung
vẫn xanh hết.
"""
from cowork_local.i18n import tr
dlg = _dialog("")
dlg.sandbox_pw_edit.setText("")
dlg._sandbox_unlock()
chua_cau_hinh = list(shown)
dlg.deleteLater()
shown.clear()
dlg2 = _dialog("K7MNP2QRSTVW")
dlg2.sandbox_pw_edit.setText("sai roi")
dlg2._sandbox_unlock()
sai_mat_khau = list(shown)
dlg2.deleteLater()
assert len(chua_cau_hinh) == 1, "phải hiện đúng một thông báo"
assert len(sai_mat_khau) == 1
assert chua_cau_hinh[0][2] == tr("settings.sandbox_pw_unset_body")
assert chua_cau_hinh[0][2] != sai_mat_khau[0][2], (
"chưa cấu hình mật khẩu và sai mật khẩu phải là hai thông báo khác nhau")
for ten in ("sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_locked_status",
"sandbox_pw_label"):
assert not hasattr(dlg, ten), f"{ten} vẫn còn trên hộp thoại"
# ---- đường đi đúng vẫn phải chạy ----------------------------------------
def test_khong_con_duong_mo_khoa_trong_ma(qapp):
"""Hàm mở khoá và cờ trạng thái khoá không còn tồn tại."""
import cowork_local.ui.settings_dialog as mod
def test_mat_khau_dung_van_mo_duoc(qapp, shown):
"""Bản vá không được phá đường đi hợp lệ."""
dlg = _dialog("K7MNP2QRSTVW")
dlg.sandbox_pw_edit.setText("K7MNP2QRSTVW")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is True
dlg.deleteLater()
dlg = _dialog()
assert not hasattr(dlg, "_sandbox_unlock")
assert not hasattr(dlg, "_sandbox_unlocked")
assert not hasattr(dlg, "_sandbox_widgets")
assert not hasattr(mod, "_sandbox_password_matches")
@pytest.mark.parametrize("pw", ["mật khẩu", "パスワード", "sénhà-2026"])
def test_mat_khau_co_dau_khong_lam_crash(qapp, shown, pw):
"""``secrets.compare_digest`` ném TypeError nếu str có ký tự ngoài ASCII.
# ---- guardrail: không ai khoá lại mà không sửa bài test này --------------
App mặc định tiếng Việt và phục vụ khách Nhật, nên chữ có dấu trong ô mật
khẩu là input bình thường. Phải so sánh trên bytes.
"""
dlg = _dialog(pw)
dlg.sandbox_pw_edit.setText(pw)
def test_ma_nguon_khong_con_khoa_nhom_sandbox():
"""Chặn cả lớp lỗi: lần sau ai thêm lại ``setEnabled(False)`` cho nhóm này
thì bài này đỏ ngay, không đợi có người mở app mới thấy."""
src = (Path(__file__).resolve().parents[2]
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
code = "\n".join(l for l in src.splitlines() if not l.strip().startswith("#"))
dlg._sandbox_unlock() # không được ném TypeError
assert dlg._sandbox_unlocked is True
dlg.deleteLater()
for dau_hieu in ("_sandbox_unlock", "_sandbox_widgets", "_sandbox_unlocked",
"sandbox_pw"):
assert dau_hieu not in code, f"khoá sandbox đã quay lại: {dau_hieu}"
def test_mat_khau_co_dau_sai_thi_van_khoa(qapp, shown):
"""Chữ có dấu không được biến thành đường mở khoá dễ dãi."""
dlg = _dialog("mật khẩu")
dlg.sandbox_pw_edit.setText("mat khau")
def test_phep_quet_thuc_su_doc_duoc_file():
"""Lưới an toàn: đổi tên file làm bài trên quét rỗng mà vẫn xanh."""
src = (Path(__file__).resolve().parents[2]
/ "ui" / "settings_dialog.py").read_text(encoding="utf-8")
dlg._sandbox_unlock()
assert dlg._sandbox_unlocked is False
dlg.deleteLater()
# ---- hàm so khớp, gọi thẳng ----------------------------------------------
@pytest.mark.parametrize("entered,stored,expected", [
("", "", False), # cả hai rỗng
("", "K7MNP2QRSTVW", False), # ô nhập rỗng
("K7MNP2QRSTVW", "", False), # chưa đặt mật khẩu — nhánh phòng thủ
("K7MNP2QRSTVW", "K7MNP2QRSTVW", True),
("mật khẩu", "mật khẩu", True), # ngoài ASCII
("mật khẩu", "mat khau", False),
])
def test_ham_so_khop(entered, stored, expected):
"""Gọi thẳng ``_sandbox_password_matches`` — phủ cả nhánh mà call site đã
chặn trước bằng return sớm."""
from cowork_local.ui.settings_dialog import _sandbox_password_matches
assert _sandbox_password_matches(entered, stored) is expected
# ---- chặn cả lớp lỗi -----------------------------------------------------
#: ``.get("<khoá kiểu credential>", "<literal khác rỗng>")`` — mặc định trông có
#: vẻ an toàn nhưng thực ra là credential nằm trong mã nguồn. Nó cũng là code
#: chết: cấu hình đã deep-merge với DEFAULT_CONFIG nên key luôn tồn tại.
#:
#: Cố ý KHÔNG bắt ``key`` và ``code`` trần: ``it.get("key", "?")`` của Jira
#: (``core/jira_tool.py``) là mã issue, không phải credential. Danh sách dưới đây
#: chỉ gồm tên đã mang nghĩa bí mật.
_CREDENTIAL_FALLBACK = re.compile(
r'\.get\(\s*["\'][a-z_]*'
r'(?:pw|passwd|password|secret|token|api_key|unlock_code|access_code)'
r'[a-z_]*["\']\s*,\s*["\'][^"\']+["\']'
)
#: Quét CHÉO mọi thư mục nguồn, không chỉ tầng giao diện. Sai sót gốc của commit
#: ``3827552`` là sửa ``config.py`` mà quên bản sao trong ``ui/`` — tức là lỗi đi
#: xuyên thư mục, nên phép quét cũng phải đi xuyên thư mục.
_SCANNED = (
"ui", "presentation", "core", "infrastructure", "application", "domain",
"mcp_servers", "providers", "security", "theme", "config.py", "state.py",
)
def test_khong_con_fallback_credential_trong_ma_nguon():
"""Không file nguồn nào được đặt credential làm giá trị mặc định của ``.get()``."""
root = Path(__file__).resolve().parents[2]
offenders = []
for name in _SCANNED:
target = root / name
if target.is_file():
files = [target]
elif target.is_dir():
files = [p for p in target.rglob("*.py") if "__pycache__" not in p.parts]
else: # thư mục bị đổi tên/xoá
continue
for path in files:
for lineno, line in enumerate(path.read_text(encoding="utf-8").splitlines(), 1):
if _CREDENTIAL_FALLBACK.search(line):
offenders.append(
f"{path.relative_to(root).as_posix()}:{lineno}: {line.strip()}")
assert not offenders, "credential nằm trong mã nguồn:\n " + "\n ".join(offenders)
def test_phep_quet_thuc_su_nhin_thay_file():
"""Lưới an toàn cho bài trên: đổi tên thư mục làm nó quét rỗng mà vẫn xanh."""
root = Path(__file__).resolve().parents[2]
seen = sum(
1 for name in _SCANNED
for _ in ([root / name] if (root / name).is_file()
else (root / name).rglob("*.py") if (root / name).is_dir() else [])
)
assert seen > 200, f"chỉ quét được {seen} file — phạm vi quét đã hỏng"
assert "class SettingsDialog" in src
assert len(src) > 2000, f"chỉ đọc được {len(src)} ký tự — đường dẫn đã hỏng"
+11 -6
View File
@@ -128,19 +128,24 @@ def test_bam_project_tren_thanh_menu_an_ngay_lan_dau(window):
def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
"""Nhánh của người dùng ĐÃ có project — nhánh mà bug được báo.
"""Nhánh của người dùng ĐÃ chọn một project — nhánh mà bug được báo.
Môi trường test không có project nào (cố ý: ``core/projects.py`` ghi vào
``~/.cowork_local`` thật). Mở cổng bằng tay để đi đúng nhánh đó mà không
phải tạo project trên đĩa.
Trước đây bài này mở cổng bằng cửa sau ``setTabVisible(True)`` vì môi trường
test không có project nào (``core/projects.py`` ghi vào ``~/.cowork_local``
thật, nên test không tạo project). Cửa sau đó hết tác dụng từ khi cổng được
điều khiển bằng ``_current_id``: ``refresh()``/``goto_all_projects()`` đóng
lại ngay. Giờ mở cổng bằng đúng đường thật — chọn một project — và bỏ qua
bài này trên máy chưa có project nào.
"""
from PySide6.QtCore import Qt
ws = window.workspace
if ws._cowork_tab_idx < 0:
pytest.skip("bản dựng này không có sub-tab Cowork")
if ws.project_list.count() == 0:
pytest.skip("máy chạy test chưa có project nào — cổng không mở được")
ws.tabs.setTabVisible(ws._cowork_tab_idx, True)
ws.project_list.setCurrentRow(0)
try:
window.goto_all_projects()
@@ -154,5 +159,5 @@ def test_khi_cong_project_MO_thi_ha_canh_o_cowork_va_vet_sang_theo(window):
f"nội dung ở Cowork ({ws._cowork_tab_idx}) "
f"nhưng thanh menu sáng ở {data.get('sub')}")
finally:
ws.tabs.setTabVisible(ws._cowork_tab_idx, False)
ws.project_list.setCurrentRow(-1) # đóng cổng lại đúng đường thật
window.goto_all_projects()
+5 -1
View File
@@ -24,6 +24,10 @@ QSplitter::handle:horizontal { width: 1px; }
QSplitter::handle:vertical { height: 1px; }
QSplitter::handle:hover { background: $border_strong; }
QStatusBar { background: $bg; color: $text_faint; border-top: 1px solid $border; }
/* Dòng phiên bản ở góc phải: đẩy chữ ra khỏi mép cửa sổ. Đặt padding ở nhãn
chứ không ở QStatusBar — margin/padding của thanh không dịch được widget mà
layout nội bộ của nó ghim vào sát mép phải. */
QStatusBar QLabel { padding-right: 10px; }
QToolTip {
background: $overlay; color: $text; border: 1px solid $border_strong;
border-radius: ${radius}px; padding: 5px 9px;
@@ -45,7 +49,7 @@ QWidget#contentArea { background: $bg; }
carries a 2px accent marker, so which section you are in survives even at a
glance or for anyone who cannot separate the two greys. */
QWidget#navWrap QTreeWidget::item, QWidget#navWrap QListWidget::item {
padding: 6px 4px; border-radius: ${radius}px;
padding: 6px 10px; border-radius: ${radius}px;
}
QWidget#navWrap QTreeWidget::item:hover, QWidget#navWrap QListWidget::item:hover {
background: $nav_hover;
+1 -1
View File
@@ -26,7 +26,7 @@ from capture_screens import _apply_theme, _isolate_home, _load_fonts # noqa: E4
SETTINGS_FIELDS = [
"language_combo", "theme_combo", "tray_chk", "notify_chk",
"provider_combo", "prov_base", "prov_key", "prov_model",
"sandbox_pw_edit", "sandbox_unlock_btn", "sandbox_confirm",
"sandbox_confirm",
"sandbox_block_network", "sec_enabled", "ai_check",
]
TASK_FIELDS = [
+25 -18
View File
@@ -74,22 +74,30 @@ def main() -> int:
main_rows, bottom_rows = rows(win.nav), rows(win.nav_bottom)
n_total = len(main_rows) + len(bottom_rows)
# Five Workspace sub-views + Schedule, then Dashboard + Monitoring.
if len(main_rows) != 6:
fails.append(f"thanh chinh co {len(main_rows)} dong, cho 6")
# Workspace gop cac sub-view DANG MO CONG + Schedule, roi Dashboard +
# Monitoring. Cowork/GraphRAG chi co mat khi da chon mot project, nen so
# dong doi theo cong thay vi co dinh 6.
mo_cong = sum(1 for _l, _i, _ic, on in win.workspace.nav_entries() if on)
cho_chinh = mo_cong + 1
if len(main_rows) != cho_chinh:
fails.append(f"thanh chinh co {len(main_rows)} dong, cho {cho_chinh}")
if len(bottom_rows) != 2:
fails.append(f"nhom day co {len(bottom_rows)} dong, cho 2")
if any(sub is not None for _l, _p, sub, _o in bottom_rows):
fails.append("nhom day khong duoc mang sub-tab")
# The two gated rows must be PRESENT (that is the point) — greyed is fine.
# Hang bi cong project dong thi BO HAN khoi menu; hang dang mo phai co mat.
labels = [r[0] for r in main_rows]
ws_labels = [lab for lab, _i, _ic, _on in win.workspace.nav_entries()]
for lab in ws_labels:
ws_mo = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if on]
ws_dong = [lab for lab, _i, _ic, on in win.workspace.nav_entries() if not on]
for lab in ws_mo:
if lab not in labels:
fails.append(f"mat dong Workspace: {lab}")
print(f"du 5 man Workspace tren thanh menu: {all(l in labels for l in ws_labels)}"
f" ({', '.join(ws_labels)})")
fails.append(f"mat dong Workspace dang mo cong: {lab}")
for lab in ws_dong:
if lab in labels:
fails.append(f"dong Workspace dang dong cong van tren menu: {lab}")
print(f"man Workspace dang mo cong tren menu: {all(l in labels for l in ws_mo)}"
f" ({', '.join(ws_mo) or 'khong co'})")
# Highlight must follow the content for every row, both ways round.
# Re-fetch items by index every time: navigating can rebuild the rail, which
@@ -145,19 +153,18 @@ def main() -> int:
if ws_strip:
fails.append("dai tab Workspace hien lai — trung voi thanh menu")
# The whole point of the change: with no project selected the two gated rows
# must stay in place, greyed — not vanish and resize the menu.
# Yeu cau: chua chon project thi Cowork/GraphRAG khong duoc hien tren menu.
win.workspace._update_tab_visibility(False)
app.processEvents()
gated = rows(win.nav)
off = [lab for lab, _p, _s, on in gated if not on]
nhan_gated = [lab for lab, _p, _s, _on in gated]
print()
print(f"chua chon project : van du {len(gated)} dong, mo: {off or 'khong'}")
if len(gated) != len(main_rows):
fails.append(f"chua chon project thi thanh menu con {len(gated)} dong "
f"(truoc {len(main_rows)}) — item van bien mat")
if len(off) != 2:
fails.append(f"cho 2 dong bi mo (Cowork, GraphRAG), thay {len(off)}")
print(f"chua chon project : con {len(gated)} dong ({', '.join(nhan_gated)})")
for lab in ("Cowork", "GraphRAG"):
if lab in nhan_gated:
fails.append(f"chua chon project ma {lab} van tren menu")
if any(not on for _l, _p, _s, on in gated):
fails.append("con dong bi mo tren menu — dang le phai bo han")
# --- rail header: project picker + new chat (Phase A) ------------------
print()
+6 -6
View File
@@ -18,7 +18,7 @@ from typing import Dict, List, Optional
from PySide6.QtCore import Qt, Signal
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QHBoxLayout, QHeaderView, QInputDialog, QLabel, QLineEdit, QMessageBox,
QHBoxLayout, QHeaderView, QLabel, QLineEdit, QMessageBox,
QPushButton, QSplitter, QTableWidget, QTableWidgetItem, QTreeWidget,
QTreeWidgetItem, QVBoxLayout, QWidget,
)
@@ -28,6 +28,7 @@ from ..core import usage_tracker as ut
from ..core.worker import AgentWorker
from ..i18n import on_language_changed, tr
from ..state import AppContext
from .dialog_buttons import ask_text, confirm, dialog_buttons
from .icons import DOT_AMBER, icon
from .widgets import fmt_tokens
@@ -151,7 +152,7 @@ class AccountEditDialog(QDialog):
self.group_combo.setCurrentIndex(idx)
self.group_combo.setEnabled(allow_group_edit)
form.addRow(tr("accounts.f_group"), self.group_combo)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -529,9 +530,8 @@ class AccountsTab(QWidget):
return
if not self._is_admin() and account.role != "user":
return # Sub-admin can only remove ordinary members, never another sub-admin
if QMessageBox.question(
self, tr("accounts.delete_title"),
tr("accounts.delete_confirm", username=account.username)) != QMessageBox.Yes:
if not confirm(self, tr("accounts.delete_title"),
tr("accounts.delete_confirm", username=account.username)):
return
accounts.delete_account(account.username, self._accounts_dir())
if account.group_id:
@@ -703,7 +703,7 @@ class AccountsTab(QWidget):
"""Hỏi tên rồi tạo một nhóm mới. Chỉ Admin."""
if not self._is_admin():
return
name, ok = QInputDialog.getText(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
name, ok = ask_text(self, tr("accounts.new_group_title"), tr("accounts.f_group_name"))
if not (ok and name.strip()):
return
group = groups.new_group(name.strip())
+4 -5
View File
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox, QFormLayout, QHBoxLayout, QLabel, QLineEdit, QListWidget,
QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton, QScrollArea,
QListWidgetItem, QPlainTextEdit, QPushButton, QScrollArea,
QSplitter, QVBoxLayout, QWidget,
)
@@ -22,6 +22,7 @@ from ..core.custom_agents import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import confirm
from .icons import icon
@@ -300,10 +301,8 @@ class AgentManagerTab(QWidget):
agent = self._current_agent()
if agent is None:
return
if QMessageBox.question(
self, tr("agentmgr.delete_btn"),
tr("agentmgr.delete_confirm", name=agent.name),
) != QMessageBox.Yes:
if not confirm(self, tr("agentmgr.delete_btn"),
tr("agentmgr.delete_confirm", name=agent.name)):
return
delete_agent(agent.name)
self._reload_list()
+212
View File
@@ -0,0 +1,212 @@
"""DF-007 — browse OneDrive/SharePoint via Microsoft Graph and pick a folder
to use as (a local mirror of) a project's working directory. See
``core/cloud_workspace_sync.py`` for the mirror/sync side and
``ui/ms365_signin_dialog.py`` for the sign-in gate this calls first.
"""
from __future__ import annotations
from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QLineEdit, QMessageBox, QPushButton,
QRadioButton, QTreeWidget, QTreeWidgetItem, QVBoxLayout,
)
from ..core import ms365_auth
from ..core import ms365_graph as graph
from ..i18n import tr
from .icons import icon
from .ms365_signin_dialog import ensure_signed_in
_ITEM_KIND = Qt.UserRole
_ITEM_NAME = Qt.UserRole + 1
class CloudFolderPickerDialog(QDialog):
"""Chọn "OneDrive của tôi" hoặc tìm 1 site SharePoint, rồi duyệt thư mục
con của nó; "Chọn thư mục này" trả về thư mục ĐANG HIỂN THỊ (không phải
dòng đang bôi đen — giống hành vi ``QFileDialog`` khi đang ở trong 1
thư mục)."""
def __init__(self, config, parent=None):
super().__init__(parent)
self._config = config
ms365 = (config.ms365 if config is not None else {}) or {}
self._token = ms365_auth.get_access_token(
ms365.get("tenant_id", ""), ms365.get("client_id", ""))
self._site_id = ""
self._site_name = ""
self._path_parts: list[str] = [] # relative path segments from the drive root
self.setWindowTitle(tr("cloud_picker.title"))
self.setModal(True)
self.resize(520, 480)
layout = QVBoxLayout(self)
source_row = QHBoxLayout()
self._onedrive_radio = QRadioButton(tr("cloud_picker.source_onedrive"))
self._onedrive_radio.setChecked(True)
self._onedrive_radio.toggled.connect(self._on_source_toggled)
self._sharepoint_radio = QRadioButton(tr("cloud_picker.source_sharepoint"))
source_row.addWidget(self._onedrive_radio)
source_row.addWidget(self._sharepoint_radio)
source_row.addStretch(1)
layout.addLayout(source_row)
search_row = QHBoxLayout()
self._site_search = QLineEdit()
self._site_search.setPlaceholderText(tr("cloud_picker.search_sites_placeholder"))
self._site_search.setEnabled(False)
self._site_search.returnPressed.connect(self._search_sites)
self._search_btn = QPushButton(tr("cloud_picker.search_btn"))
self._search_btn.setEnabled(False)
self._search_btn.clicked.connect(self._search_sites)
search_row.addWidget(self._site_search, 1)
search_row.addWidget(self._search_btn)
layout.addLayout(search_row)
self._path_lbl = QLabel()
self._path_lbl.setWordWrap(True)
layout.addWidget(self._path_lbl)
self._tree = QTreeWidget()
self._tree.setHeaderHidden(True)
self._tree.itemDoubleClicked.connect(self._on_item_activated)
layout.addWidget(self._tree, 1)
btn_row = QHBoxLayout()
self._choose_btn = QPushButton(tr("cloud_picker.choose_here"))
self._choose_btn.setObjectName("primary")
self._choose_btn.setIcon(icon("cloud"))
self._choose_btn.clicked.connect(self.accept)
self._cancel_btn = QPushButton(tr("cloud_picker.cancel"))
self._cancel_btn.clicked.connect(self.reject)
btn_row.addStretch(1)
btn_row.addWidget(self._cancel_btn)
btn_row.addWidget(self._choose_btn)
layout.addLayout(btn_row)
self._refresh_path_label()
self._reload()
# ---- source switching -------------------------------------------------
def _on_source_toggled(self, _checked: bool) -> None:
is_sharepoint = self._sharepoint_radio.isChecked()
self._site_search.setEnabled(is_sharepoint)
self._search_btn.setEnabled(is_sharepoint)
self._choose_btn.setEnabled(not is_sharepoint or bool(self._site_id))
if not is_sharepoint:
self._site_id = ""
self._site_name = ""
self._path_parts = []
self._refresh_path_label()
self._reload()
def _search_sites(self) -> None:
query = self._site_search.text().strip()
if not query:
return
self._tree.clear()
try:
sites = graph.list_sharepoint_sites(self._token, query)
except graph.Ms365GraphError as exc:
QMessageBox.warning(self, tr("cloud_picker.title"),
tr("cloud_picker.load_failed", err=str(exc)))
return
if not sites:
item = QTreeWidgetItem([tr("cloud_picker.no_sites")])
item.setData(0, _ITEM_KIND, "empty")
self._tree.addTopLevelItem(item)
return
for site in sites:
item = QTreeWidgetItem([site.get("displayName") or site.get("name") or site.get("id", "")])
item.setIcon(0, icon("globe"))
item.setData(0, _ITEM_KIND, "site")
item.setData(0, _ITEM_NAME, site.get("id", ""))
item.setData(0, Qt.UserRole + 2, site.get("displayName") or site.get("name") or "")
self._tree.addTopLevelItem(item)
self._choose_btn.setEnabled(False) # must pick a site before choosing a folder
def _on_item_activated(self, item: QTreeWidgetItem, _col: int) -> None:
kind = item.data(0, _ITEM_KIND)
if kind == "site":
self._site_id = item.data(0, _ITEM_NAME)
self._site_name = item.data(0, Qt.UserRole + 2)
self._path_parts = []
self._choose_btn.setEnabled(True)
self._refresh_path_label()
self._reload()
elif kind == "up":
self._path_parts.pop()
self._refresh_path_label()
self._reload()
elif kind == "folder":
self._path_parts.append(item.data(0, _ITEM_NAME))
self._refresh_path_label()
self._reload()
# kind == "file": not navigable, double-click does nothing
# ---- listing ------------------------------------------------------------
def _current_remote_path(self) -> str:
return "/".join(self._path_parts)
def _refresh_path_label(self) -> None:
if self._sharepoint_radio.isChecked():
root = self._site_name or tr("cloud_picker.source_sharepoint")
else:
root = tr("cloud_picker.source_onedrive")
parts = "/".join([root] + self._path_parts)
self._path_lbl.setText(parts)
def _reload(self) -> None:
self._tree.clear()
if self._sharepoint_radio.isChecked() and not self._site_id:
return # waiting on a site search/selection
if self._path_parts:
up = QTreeWidgetItem([tr("cloud_picker.up")])
up.setData(0, _ITEM_KIND, "up")
self._tree.addTopLevelItem(up)
try:
if self._sharepoint_radio.isChecked():
children = graph.list_sharepoint_files(
self._token, self._site_id, self._current_remote_path())
else:
children = graph.list_onedrive_files(self._token, self._current_remote_path())
except graph.Ms365GraphError as exc:
QMessageBox.warning(self, tr("cloud_picker.title"),
tr("cloud_picker.load_failed", err=str(exc)))
return
for entry in children:
name = entry.get("name", "")
if not name:
continue
is_folder = "folder" in entry
item = QTreeWidgetItem([name])
item.setIcon(0, icon("folder" if is_folder else "file"))
item.setData(0, _ITEM_KIND, "folder" if is_folder else "file")
item.setData(0, _ITEM_NAME, name)
self._tree.addTopLevelItem(item)
# ---- result ---------------------------------------------------------
def cloud_source(self) -> dict:
"""Chỉ gọi sau khi ``exec()`` trả về ``QDialog.Accepted``."""
if self._sharepoint_radio.isChecked():
return {
"provider": "sharepoint", "site_id": self._site_id,
"site_name": self._site_name, "remote_path": self._current_remote_path(),
}
return {"provider": "onedrive", "site_id": "", "site_name": "",
"remote_path": self._current_remote_path()}
def pick_cloud_folder(parent, config) -> Optional[dict]:
"""Đảm bảo đã đăng nhập MS365 rồi mở dialog duyệt; trả về ``cloud_source``
dict nếu người dùng xác nhận một thư mục, ``None`` nếu hủy hoặc chưa đăng
nhập."""
if not ensure_signed_in(parent, config):
return None
dialog = CloudFolderPickerDialog(config, parent)
if dialog.exec() == QDialog.Accepted:
return dialog.cloud_source()
return None
+4 -3
View File
@@ -9,7 +9,7 @@ from typing import List
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout, QInputDialog,
QComboBox, QDialog, QDialogButtonBox, QFormLayout, QHBoxLayout,
QLineEdit, QListWidget, QListWidgetItem, QPlainTextEdit, QPushButton,
QVBoxLayout, QWidget,
)
@@ -17,6 +17,7 @@ from PySide6.QtWidgets import (
from ..core.co4e import PERMISSION_PRESETS, CustomAgent
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline, dialog_buttons
from .icons import icon, icon_picker_combo
@@ -114,7 +115,7 @@ class Co4EAgentDialog(QDialog):
form.addRow(tr("co4e.f_attachments"), self.attach_list)
form.addRow("", abtn)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject)
form.addRow(buttons)
@@ -129,7 +130,7 @@ class Co4EAgentDialog(QDialog):
role = self.role_edit.text().strip()
if not name and not role:
return
hint, ok = QInputDialog.getMultiLineText(
hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok:
return
+11 -9
View File
@@ -13,7 +13,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit, QMessageBox,
QDialog, QFormLayout, QFrame, QHBoxLayout, QLabel, QLineEdit,
QPushButton, QScrollArea, QVBoxLayout, QWidget,
)
@@ -21,6 +21,7 @@ from ..core.ext_connectors import CATEGORIES as EXT_CATEGORIES
from ..core.worker import AgentWorker
from ..i18n import bind_text, on_language_changed, tr
from ..state import AppContext
from .dialog_buttons import confirm
from .ext_connector_dialog import ExtConnectorEditDialog
from .icons import icon
from .widgets import FlowLayout, ToggleSwitch, enable_height_for_width, style_card
@@ -129,11 +130,13 @@ class ConnectorsPanel(QWidget):
Có một công tắc tổng: tắt là agent không nối ra connector ngoài nào cả.
"""
# Khoá dịch, không phải chữ sẵn: nhãn nhóm dựng lại trong _reload_connectors
# nên tr() ở đó là đủ (xem i18n/connectors.py).
_EXT_CATEGORY_LABELS = {
"cad": "CAD (NX / CATIA / SolidWorks / AutoCAD)",
"cae": "CAE (ANSA / ABAQUS / HyperWorks / ANSYS)",
"ms365": "MS365 (Microsoft 365 / OneDrive / SharePoint)",
"other": "Other (any generic MCP server)",
"cad": "connectors.cat_cad",
"cae": "connectors.cat_cae",
"ms365": "connectors.cat_ms365",
"other": "connectors.cat_other",
}
_EXT_CATEGORY_ICONS = {"cad": "wrench", "cae": "ruler", "ms365": "cloud", "other": "plug"}
# TEMPORARY: only OneDrive + SharePoint (auto-connect via locally-synced
@@ -232,7 +235,7 @@ class ConnectorsPanel(QWidget):
icon_lbl = QLabel()
icon_lbl.setPixmap(icon(self._EXT_CATEGORY_ICONS.get(cat, "plug"), size=18).pixmap(18, 18))
hdr.addWidget(icon_lbl)
name, _, subtitle = self._EXT_CATEGORY_LABELS.get(cat, cat).partition(" (")
name, _, subtitle = tr(self._EXT_CATEGORY_LABELS.get(cat, cat)).partition(" (")
name_lbl = QLabel(name)
name_lbl.setStyleSheet("font-weight:700;")
hdr.addWidget(name_lbl)
@@ -373,9 +376,8 @@ class ConnectorsPanel(QWidget):
def _delete_ext_entry(self, cat: str, entry: dict) -> None:
"""Xoá một connector MCP sau khi hỏi xác nhận."""
if QMessageBox.question(
self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))) != QMessageBox.Yes:
if not confirm(self, tr("settings.ext_delete_btn"),
tr("settings.ext_delete_confirm", name=entry.get("name", ""))):
return
self.ctx.config.ext_connectors[cat].remove(entry)
self.ctx.save()
+110
View File
@@ -0,0 +1,110 @@
"""Nút hộp thoại mang chữ của ứng dụng, không phải chữ mặc định của Qt.
``QDialogButtonBox(Save | Cancel)``, ``QMessageBox.question`` và
``QInputDialog.get*`` lấy nhãn nút từ bảng dịch của CHÍNH Qt. Ứng dụng không cài
``QTranslator`` nào — và bản PySide6 đang dùng cũng không đóng gói sẵn file
``qtbase_*.qm`` nào để cài — nên bảng đó luôn rơi về tiếng Anh: chọn tiếng Nhật
thì hộp thoại vẫn hiện "Save" / "Cancel" / "&Yes" / "&No". Đây là lỗi người dùng
báo, và nó không nằm ở dây nối i18n của dự án — ``tr()`` chưa từng được hỏi tới.
Các hàm dưới đây gán nhãn của dự án đè lên nhãn Qt. Dùng chúng thay cho việc
dựng ``QDialogButtonBox`` / gọi ``QMessageBox.question`` / ``QInputDialog.get*``
trực tiếp.
"""
from __future__ import annotations
from typing import Callable, Iterable, Tuple
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QInputDialog, QMessageBox, QWidget,
)
from ..i18n import bind_text, tr
#: tên gọi trong ``**override`` -> (cờ nút chuẩn, khoá dịch mặc định)
_STANDARD = {
"save": (QDialogButtonBox.Save, "dialog.save"),
"cancel": (QDialogButtonBox.Cancel, "dialog.cancel"),
"ok": (QDialogButtonBox.Ok, "dialog.ok"),
"close": (QDialogButtonBox.Close, "dialog.close"),
}
def dialog_buttons(standard, **override: str) -> QDialogButtonBox:
"""``QDialogButtonBox`` có nhãn đổi theo ngôn ngữ đang chọn.
``override`` đổi khoá dịch của một nút cụ thể — ``ok="schedtask.ai_confirm"``
cho hộp thoại muốn nút OK mang chữ riêng. Phải đi qua đây chứ không
``setText(tr(...))`` sau khi dựng: lần đổi ngôn ngữ kế tiếp, ràng buộc bên
dưới sẽ áp lại khoá mặc định và xoá mất chữ riêng đó.
"""
box = QDialogButtonBox(standard)
for name, (flag, key) in _STANDARD.items():
button = box.button(flag)
if button is not None:
bind_text(button, override.get(name, key))
return box
def confirm(parent: QWidget | None, title: str, body: str) -> bool:
"""Hỏi Có/Không, trả về True nếu người dùng đồng ý.
Thay cho ``QMessageBox.question(...) == QMessageBox.Yes``, vốn hiện "&Yes" /
"&No" tiếng Anh ở mọi ngôn ngữ. Hộp thoại dựng lại mỗi lần mở nên gọi
``tr()`` một lần khi dựng là đủ, không cần ràng buộc.
"""
box = QMessageBox(QMessageBox.Question, title, body,
QMessageBox.Yes | QMessageBox.No, parent)
box.button(QMessageBox.Yes).setText(tr("dialog.yes"))
box.button(QMessageBox.No).setText(tr("dialog.no"))
return box.exec() == QMessageBox.Yes
def _hoi(parent: QWidget | None, title: str, label: str,
dung: Callable[[QInputDialog], None]) -> Tuple[str, bool]:
"""Khung chung của ba hàm hỏi bên dưới; ``dung`` chỉnh riêng từng kiểu.
Các hàm tĩnh ``QInputDialog.get*`` tự dựng hộp thoại bên trong nên không
chạm được vào nút của nó — phải tự dựng thì mới đặt được nhãn.
"""
dlg = QInputDialog(parent)
dlg.setWindowTitle(title)
dlg.setLabelText(label)
dlg.setOkButtonText(tr("dialog.ok"))
dlg.setCancelButtonText(tr("dialog.cancel"))
dung(dlg)
# exec() TRƯỚC, đọc giá trị SAU: gói cả hai vào một tuple thì Python tính
# textValue() trước và trả về chữ có sẵn lúc chưa ai gõ gì.
dong_y = dlg.exec() == QDialog.Accepted
return dlg.textValue(), dong_y
def ask_text(parent: QWidget | None, title: str, label: str,
text: str = "") -> Tuple[str, bool]:
"""Như ``QInputDialog.getText``, nhưng nút bấm theo ngôn ngữ đang chọn."""
return _hoi(parent, title, label, lambda d: d.setTextValue(text))
def ask_multiline(parent: QWidget | None, title: str, label: str,
text: str = "") -> Tuple[str, bool]:
"""Như ``QInputDialog.getMultiLineText``, nút bấm theo ngôn ngữ đang chọn."""
def dung(d: QInputDialog) -> None:
d.setOption(QInputDialog.UsePlainTextEditForTextInput)
d.setTextValue(text)
return _hoi(parent, title, label, dung)
def ask_item(parent: QWidget | None, title: str, label: str,
items: Iterable[str], current: int = 0,
editable: bool = False) -> Tuple[str, bool]:
"""Như ``QInputDialog.getItem``, nút bấm theo ngôn ngữ đang chọn."""
muc = list(items)
def dung(d: QInputDialog) -> None:
d.setComboBoxItems(muc)
d.setComboBoxEditable(editable)
if muc:
d.setTextValue(muc[current])
return _hoi(parent, title, label, dung)
+2 -1
View File
@@ -18,6 +18,7 @@ from PySide6.QtWidgets import (
from ..core.ext_connectors import CATEGORIES, PRESETS
from ..i18n import tr
from .dialog_buttons import dialog_buttons
# ms365 has no user-created entries here (see ConnectorsPanel) — it auto-connects
# via its own built-in OneDrive/SharePoint toggles, so it's left off this picker.
@@ -119,7 +120,7 @@ class ExtConnectorEditDialog(QDialog):
self._enabled = bool(connector.get("enabled", False))
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+9
View File
@@ -96,11 +96,20 @@ class FileEditDialog(QDialog):
# ---- AI edit row ---------------------------------------------------
ai_row = QHBoxLayout()
ai_row.setContentsMargins(0, 6, 0, 2)
self.instruction_edit = QLineEdit()
self.instruction_edit.setPlaceholderText(tr("fileedit.instruction_placeholder"))
self.instruction_edit.setMinimumHeight(38)
self.instruction_edit.setStyleSheet(
"QLineEdit { padding: 8px 12px; font-size: 13px; border-radius: 6px; }"
)
self.instruction_edit.returnPressed.connect(self._ai_edit)
self.ai_btn = QPushButton(tr("fileedit.ai_btn"))
self.ai_btn.setIcon(icon("sparkle"))
self.ai_btn.setMinimumHeight(38)
self.ai_btn.setStyleSheet(
"QPushButton { padding: 0 16px; font-size: 13px; border-radius: 6px; }"
)
self.ai_btn.clicked.connect(self._ai_edit)
ai_row.addWidget(self.instruction_edit, 1)
ai_row.addWidget(self.ai_btn)
+8 -7
View File
@@ -9,7 +9,7 @@ from __future__ import annotations
from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import (
QHBoxLayout, QInputDialog, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
@@ -17,6 +17,7 @@ from ..core import custom_icons
from ..i18n import on_language_changed, tr
from ..state import AppContext
from . import icons as icons_mod
from .dialog_buttons import ask_multiline, ask_text
from .icons import icon
@@ -116,8 +117,8 @@ class IconsAdminTab(QWidget):
path, _ = QFileDialog.getOpenFileName(self, tr("icons_admin.add"), "", "SVG (*.svg)")
if not path:
return
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
name, ok = ask_text(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
if not ok:
return
try:
@@ -129,12 +130,12 @@ class IconsAdminTab(QWidget):
def _add_from_svg_text(self) -> None:
"""Thêm icon bằng cách dán thẳng mã SVG."""
name, ok = QInputDialog.getText(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
name, ok = ask_text(self, tr("icons_admin.name_prompt"),
tr("icons_admin.name_prompt"))
if not ok or not name.strip():
return
svg, ok = QInputDialog.getMultiLineText(self, tr("icons_admin.paste"),
tr("icons_admin.paste_prompt"))
svg, ok = ask_multiline(self, tr("icons_admin.paste"),
tr("icons_admin.paste_prompt"))
if not ok:
return
try:
+2 -1
View File
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
)
from ..i18n import tr
from .dialog_buttons import dialog_buttons
class McpServerEditDialog(QDialog):
@@ -41,7 +42,7 @@ class McpServerEditDialog(QDialog):
self.args.setPlaceholderText(tr("mcp.args_placeholder"))
lay.addWidget(self.args)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
+137
View File
@@ -0,0 +1,137 @@
"""Microsoft 365 sign-in dialog (DF-007) — thin UI over the working device-code
flow in ``core/ms365_auth.py``. There was an older MS365 sign-in UI in this
app; it was removed as dead code (no entry point — see
``ui/settings_dialog.py`` module docstring) before this feature existed, so
this is a fresh, small dialog rather than a resurrection of that one.
Usage: ``if ensure_signed_in(parent, ctx.config): ...`` — returns ``True``
immediately (no dialog shown) when already signed in.
"""
from __future__ import annotations
import webbrowser
from PySide6.QtCore import QThread, Signal
from PySide6.QtWidgets import (
QDialog, QHBoxLayout, QLabel, QMessageBox, QPushButton, QVBoxLayout,
)
from ..core import ms365_auth
from ..i18n import tr
class _SignInWorker(QThread):
"""Chạy ``sign_in_device_code()`` (blocking, poll tới khi xong/hết hạn) ở
luồng nền — xem ``core/worker.py::AgentWorker`` cho cùng idiom (bắt hết
exception, phát signal thay vì để lỗi giết luồng âm thầm)."""
code_ready = Signal(dict)
finished_ok = Signal(dict)
failed = Signal(str)
def __init__(self, config, parent=None):
super().__init__(parent)
self._config = config
def run(self) -> None: # noqa: D401
try:
result = ms365_auth.sign_in(lambda flow: self.code_ready.emit(flow), self._config)
self.finished_ok.emit(result or {})
except Exception as exc: # noqa: BLE001 - surfaced to the UI, never crashes the thread
self.failed.emit(str(exc))
class Ms365SignInDialog(QDialog):
"""Modal: hiện user_code + verification_uri, tự mở trình duyệt, đóng lại
khi đăng nhập xong (hoặc người dùng bấm Hủy)."""
def __init__(self, config, parent=None):
super().__init__(parent)
self._config = config
self._worker: _SignInWorker | None = None
self.setWindowTitle(tr("ms365_signin.title"))
self.setModal(True)
layout = QVBoxLayout(self)
self._intro_lbl = QLabel(tr("ms365_signin.intro"))
self._intro_lbl.setWordWrap(True)
layout.addWidget(self._intro_lbl)
self._code_lbl = QLabel()
self._code_lbl.setWordWrap(True)
self._code_lbl.hide()
layout.addWidget(self._code_lbl)
self._open_link_btn = QPushButton(tr("ms365_signin.open_link"))
self._open_link_btn.hide()
self._open_link_btn.clicked.connect(self._open_link)
layout.addWidget(self._open_link_btn)
self._error_lbl = QLabel()
self._error_lbl.setWordWrap(True)
self._error_lbl.setStyleSheet("color: #c0392b;")
self._error_lbl.hide()
layout.addWidget(self._error_lbl)
btn_row = QHBoxLayout()
self._signin_btn = QPushButton(tr("ms365_signin.button"))
self._signin_btn.setObjectName("primary")
self._signin_btn.clicked.connect(self._start_sign_in)
self._cancel_btn = QPushButton(tr("ms365_signin.cancel"))
self._cancel_btn.clicked.connect(self.reject)
btn_row.addStretch(1)
btn_row.addWidget(self._cancel_btn)
btn_row.addWidget(self._signin_btn)
layout.addLayout(btn_row)
self._verification_uri = ""
def _open_link(self) -> None:
if self._verification_uri:
webbrowser.open(self._verification_uri)
def _start_sign_in(self) -> None:
self._signin_btn.setEnabled(False)
self._signin_btn.setText(tr("ms365_signin.signing_in"))
self._error_lbl.hide()
self._worker = _SignInWorker(self._config, self)
self._worker.code_ready.connect(self._on_code_ready)
self._worker.finished_ok.connect(self._on_finished_ok)
self._worker.failed.connect(self._on_failed)
self._worker.start()
def _on_code_ready(self, flow: dict) -> None:
self._verification_uri = flow.get("verification_uri_complete") or flow.get(
"verification_uri", "")
self._code_lbl.setText(
tr("ms365_signin.code_hint", url=flow.get("verification_uri", "")) +
f"\n\n{flow.get('user_code', '')}")
self._code_lbl.show()
self._open_link_btn.show()
if self._verification_uri:
webbrowser.open(self._verification_uri)
def _on_finished_ok(self, _result: dict) -> None:
self.accept()
def _on_failed(self, err: str) -> None:
self._signin_btn.setEnabled(True)
self._signin_btn.setText(tr("ms365_signin.button"))
self._error_lbl.setText(tr("ms365_signin.failed", err=err))
self._error_lbl.show()
def reject(self) -> None:
# NOTE: MSAL's acquire_token_by_device_flow() has no cancellation hook,
# so a worker already polling keeps polling in the background until it
# times out on its own (a few minutes) — closing this dialog just stops
# the UI from waiting on it. Its late signals are harmless no-ops
# against an already-closed (but not destroyed) dialog.
super().reject()
def ensure_signed_in(parent, config) -> bool:
"""True nếu đã (hoặc vừa) đăng nhập MS365; False nếu người dùng hủy hoặc
đăng nhập thất bại và đóng dialog."""
if ms365_auth.is_signed_in(config):
return True
dialog = Ms365SignInDialog(config, parent)
return dialog.exec() == QDialog.Accepted
+8 -82
View File
@@ -13,19 +13,17 @@ chưa từng được gán nên gọi vào là AttributeError.
"""
from __future__ import annotations
import secrets
from PySide6.QtCore import Qt
from PySide6.QtGui import QGuiApplication
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDialog, QDialogButtonBox, QFileDialog, QFormLayout,
QGroupBox, QHBoxLayout, QLabel, QLineEdit, QListWidget, QListWidgetItem,
QMessageBox, QPushButton, QScrollArea, QSpinBox,
QGroupBox, QHBoxLayout, QListWidget, QListWidgetItem,
QScrollArea, QSpinBox,
QTreeWidgetItem, QVBoxLayout, QWidget,
)
from ..i18n import tr
from .icons import IconLabel
from .dialog_buttons import dialog_buttons
from .widgets import ToggleSwitch
@@ -36,25 +34,6 @@ from ..presentation.settings.routing_settings_widget import RoutingSettingsWidge
from ..presentation.settings.about_widget import AboutSettingsWidget
def _sandbox_password_matches(entered: str, stored: str) -> bool:
"""Whether ``entered`` unlocks the Sandbox Security group.
An empty ``stored`` must never match. ``DEFAULT_CONFIG`` ships
``agent_security.sandbox_pw = ""`` and the config handed to this dialog is
always deep-merged with those defaults, so a plain ``entered == stored``
accepts an empty field on every install that never set a password. The MS365
unlock guards the same way — see ``json_config_repository.unlock_ms365``.
Both sides are compared as UTF-8 bytes, not as ``str``:
``compare_digest`` raises ``TypeError`` on ``str`` holding anything outside
ASCII, and this app defaults to Vietnamese and ships to Japanese customers,
so an accented password is ordinary input rather than an edge case.
"""
if not entered or not stored:
return False
return secrets.compare_digest(entered.encode("utf-8"), stored.encode("utf-8"))
class SettingsDialog(QDialog):
"""Hộp thoại Cài đặt: cột mục lục bên trái, các trang bên phải
(Nhà cung cấp · Connectors · Định tuyến · Tham số · Chung).
@@ -107,29 +86,10 @@ class SettingsDialog(QDialog):
self.sandbox_group = QGroupBox(tr("settings.group.sandbox"))
sbl = QVBoxLayout(self.sandbox_group)
# --- Password protection for Sandbox Security (at top) ---
self.sandbox_pw_label = IconLabel("lock", "Sandbox Security Password")
sbl.addWidget(self.sandbox_pw_label)
pw_row = QHBoxLayout()
self.sandbox_pw_edit = QLineEdit("")
self.sandbox_pw_edit.setPlaceholderText("Enter password to edit sandbox settings")
self.sandbox_pw_edit.setEchoMode(QLineEdit.Password)
pw_row.addWidget(self.sandbox_pw_edit, 1)
self.sandbox_unlock_btn = QPushButton("Unlock")
self.sandbox_unlock_btn.clicked.connect(self._sandbox_unlock)
pw_row.addWidget(self.sandbox_unlock_btn)
self.sandbox_locked_status = IconLabel("lock", "Locked (changes disabled)", color="#c00")
self.sandbox_locked_status.text_label().setStyleSheet("color: #c00; font-weight: bold;")
pw_row.addWidget(self.sandbox_locked_status)
sbl.addLayout(pw_row)
self._sandbox_unlocked = False # Start LOCKED — must enter password first
self._sandbox_pw = sec.get("sandbox_pw", "")
# Separator line between pw section and sandbox settings
pw_sep = QLabel("────────────────")
sbl.addWidget(pw_sep)
# Nhóm này KHÔNG còn khoá bằng mật khẩu: bốn công tắc dưới đây bật/tắt
# tự do. Khoá cũ chỉ là rào chống bấm nhầm ở phía giao diện, không phải
# cơ chế bảo mật thật (rào thật nằm ở sandbox lúc chạy lệnh), nên bỏ đi
# theo yêu cầu thay vì giữ một bước nhập mật khẩu không bảo vệ được gì.
self.sandbox_confirm = ToggleSwitch(tr("settings.sandbox_confirm_commands"))
self.sandbox_confirm.setChecked(bool(sec.get("cowork_confirm_commands", False)))
self.sandbox_confirm.setToolTip(tr("settings.sandbox_confirm_commands_tooltip"))
@@ -159,14 +119,6 @@ class SettingsDialog(QDialog):
# Resource limits (CPU/Memory/Disk I/O) moved to the Parameter group
# below — see _param_section("settings.group.sandbox_limits").
# Collect all sandbox-editable widgets and lock them until unlocked
self._sandbox_widgets = [
self.sandbox_confirm, self.sandbox_block_network,
self.ai_check, self.sec_enabled,
]
for _w in self._sandbox_widgets:
_w.setEnabled(False)
root.addWidget(self.sandbox_group)
# Connectors (MCP / REST API) are managed entirely in Monitoring → Tools
@@ -229,7 +181,7 @@ class SettingsDialog(QDialog):
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._save)
buttons.rejected.connect(self.reject)
outer.addWidget(buttons)
@@ -298,32 +250,6 @@ class SettingsDialog(QDialog):
def _sandbox_unlock(self) -> None:
"""Mở khoá nhóm cài đặt sandbox bằng mật khẩu.
Đây là khoá phía giao diện để chặn bấm nhầm vào một mục nhạy cảm, KHÔNG
phải cơ chế bảo mật thật.
"""
pw = self.sandbox_pw_edit.text()
if not self._sandbox_pw:
# No password configured. Refusing with "wrong password" would be a
# dead end — the user would keep retrying a password that cannot
# exist — so name the actual state instead.
QMessageBox.warning(self, tr("settings.sandbox_pw_unset_title"),
tr("settings.sandbox_pw_unset_body"))
return
if _sandbox_password_matches(pw, self._sandbox_pw):
self._sandbox_unlocked = True
self.sandbox_locked_status.setText("Unlocked")
self.sandbox_locked_status.set_icon("unlock", "#090")
self.sandbox_locked_status.text_label().setStyleSheet("color: #090; font-weight: bold;")
# Enable all sandbox widgets
for w in self._sandbox_widgets:
w.setEnabled(True)
QMessageBox.information(self, "Sandbox Security", "Sandbox settings unlocked.")
else:
QMessageBox.warning(self, "Wrong Password", "Password incorrect. Sandbox settings remain locked.")
def _save(self) -> None:
"""Gom cấu hình từ mọi trang con rồi ghi xuống đĩa."""
data = self.ctx.config.data
+8 -9
View File
@@ -4,8 +4,8 @@ from __future__ import annotations
from PySide6.QtCore import Qt, Signal
from PySide6.QtGui import QColor, QPainter, QPen
from PySide6.QtWidgets import (
QAbstractItemView, QApplication, QHBoxLayout, QInputDialog, QLabel,
QLineEdit, QMenu, QMessageBox, QPushButton, QStyle, QStyledItemDelegate,
QAbstractItemView, QApplication, QHBoxLayout, QLabel,
QLineEdit, QMenu, QPushButton, QStyle, QStyledItemDelegate,
QStyleOptionViewItem, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget,
)
@@ -14,6 +14,7 @@ from ..core.history import (
rename_conversation, set_pinned,
)
from ..i18n import on_language_changed, tr
from .dialog_buttons import ask_text, confirm
from .icons import collapse_left_icon, dot_icon, DOT_BLUE, icon
from .widgets import CollapseStrip
from ..state import AppContext
@@ -346,15 +347,14 @@ class HistorySidebar(QWidget):
set_pinned(path, not pinned)
self.refresh()
elif chosen == rename_act:
new, ok = QInputDialog.getText(
new, ok = ask_text(
self, tr("sidebar.rename.title"), tr("sidebar.rename.label"), text=title)
if ok and new.strip():
rename_conversation(path, new.strip())
self.refresh()
elif chosen == del_act:
if QMessageBox.question(
self, tr("sidebar.delete.title"), tr("sidebar.delete.confirm", title=title)
) == QMessageBox.Yes:
if confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete.confirm", title=title)):
delete_conversation(path)
self.refresh()
self.history_changed.emit()
@@ -374,9 +374,8 @@ class HistorySidebar(QWidget):
"""Confirm, then delete every conversation in ``selected``. Split out
of _bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question(
self, tr("sidebar.delete.title"),
tr("sidebar.delete_multi.confirm", n=len(selected))) != QMessageBox.Yes:
if not confirm(self, tr("sidebar.delete.title"),
tr("sidebar.delete_multi.confirm", n=len(selected))):
return False
for item in selected:
path = item.data(0, Qt.UserRole)
+3 -2
View File
@@ -12,7 +12,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QFileDialog, QHBoxLayout, QInputDialog, QLabel, QListWidget,
QFileDialog, QHBoxLayout, QLabel, QListWidget,
QListWidgetItem, QMessageBox, QPushButton, QVBoxLayout, QWidget,
)
@@ -22,6 +22,7 @@ from ..core.skills import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline
from .icons import icon
from .skills_dialog import SkillEditDialog
@@ -100,7 +101,7 @@ class SkillManagerTab(QWidget):
QMessageBox.information(self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_unavailable"))
return
prompt, ok = QInputDialog.getMultiLineText(
prompt, ok = ask_multiline(
self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_prompt"), "")
if not ok or not prompt.strip():
+4 -3
View File
@@ -5,7 +5,7 @@ from typing import Optional
from PySide6.QtCore import Qt
from PySide6.QtWidgets import (
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QInputDialog, QLabel,
QDialog, QDialogButtonBox, QFileDialog, QHBoxLayout, QLabel,
QLineEdit, QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit,
QPushButton, QVBoxLayout,
)
@@ -17,6 +17,7 @@ from ..core.skills import (
)
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_multiline, dialog_buttons
from .icons import icon
@@ -58,7 +59,7 @@ class SkillEditDialog(QDialog):
self.instr.setMinimumHeight(180)
lay.addWidget(self.instr)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self._on_accept)
buttons.rejected.connect(self.reject)
lay.addWidget(buttons)
@@ -192,7 +193,7 @@ class SkillsDialog(QDialog):
QMessageBox.information(self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_unavailable"))
return
prompt, ok = QInputDialog.getMultiLineText(
prompt, ok = ask_multiline(
self, tr("skills.auto_generate_title"),
tr("skills.auto_generate_prompt"), "")
if not ok or not prompt.strip():
+9 -9
View File
@@ -17,7 +17,7 @@ from typing import Dict, List, Optional
from PySide6.QtCore import QDateTime, Qt
from PySide6.QtWidgets import (
QCheckBox, QComboBox, QDateTimeEdit, QDialog, QDialogButtonBox, QFileDialog,
QFormLayout, QGroupBox, QHBoxLayout, QInputDialog, QLabel, QLineEdit,
QFormLayout, QGroupBox, QHBoxLayout, QLabel, QLineEdit,
QListWidget, QListWidgetItem, QMessageBox, QPlainTextEdit, QPushButton,
QScrollArea, QSpinBox, QTabWidget, QVBoxLayout, QWidget,
)
@@ -30,6 +30,7 @@ from ..core.tasks import (
from ..core.projects import list_projects
from ..core.worker import AgentWorker
from ..i18n import tr
from .dialog_buttons import ask_text, dialog_buttons
from .icons import icon
# Only these two task types can be picked when adding/editing a task — Flow,
@@ -451,7 +452,7 @@ class TaskEditorDialog(QDialog):
# matching the five real group boxes, so you always see which group you
# are in and how many are left. (Not tabs: the audit page asks for this
# shape specifically, for consistency with Settings.)
from .widgets import section_panels
from .widgets import guard_wheel, section_panels
self._step_keys = ["schedtask.g_basic", "schedtask.g_schedule",
"schedtask.g_input", "schedtask.g_dependency",
@@ -481,7 +482,7 @@ class TaskEditorDialog(QDialog):
widest = max(w.widget().sizeHint().width() for _lab, w in pages)
self.setMinimumWidth(self.section_list.width() + widest + 60)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.button(QDialogButtonBox.Save).setIcon(icon("save"))
buttons.button(QDialogButtonBox.Cancel).setIcon(icon("close"))
buttons.accepted.connect(self._save)
@@ -490,7 +491,6 @@ class TaskEditorDialog(QDialog):
self._apply_hints()
# Scrolling the form must never spin a combo/spin/date box the cursor
# happens to hover — values only change after clicking into a field.
from .widgets import guard_wheel
guard_wheel(self)
def _retranslate_steps(self) -> None:
@@ -540,8 +540,8 @@ class TaskEditorDialog(QDialog):
def _add_link(self) -> None:
"""Thêm một link vào danh sách dữ liệu đầu vào của task."""
url, ok = QInputDialog.getText(self, tr("schedtask.add_link_title"),
tr("schedtask.add_link_label"))
url, ok = ask_text(self, tr("schedtask.add_link_title"),
tr("schedtask.add_link_label"))
url = url.strip()
if ok and url:
self.links_list.addItem(url)
@@ -607,7 +607,7 @@ class TaskEditorDialog(QDialog):
candidates = self.all_tasks + ([self._original] if self._original else [self.task])
err = chain_error(candidates, self.task["task_id"], self.next_combo.currentData())
nxt_id = self.next_combo.currentData()
warn = err or ""
warn = tr(err) if err else ""
if not err and nxt_id:
nxt = next((t for t in self.all_tasks if t["task_id"] == nxt_id), None)
if nxt and nxt.get("status") == "paused":
@@ -719,12 +719,12 @@ class TaskEditorDialog(QDialog):
err = chain_error(self.all_tasks + [self.task], self.task["task_id"],
self.next_combo.currentData())
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
deps = self._checked_depends_on()
err = depends_cycle_error(self.all_tasks + [self.task], self.task["task_id"], deps)
if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err)
QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return
t = self.task
t["title"] = title
+139 -7
View File
@@ -63,10 +63,9 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
"""(label, index, icon_name, enabled) for EVERY sub-tab, hidden ones
included.
The rail lists all five all the time and greys out the ones the project
gate is currently closing (Cowork, GraphRAG) instead of removing them —
same gate, shown rather than hidden, so the menu stops changing shape
under the user's hand. See nav_subtabs() for the visible-only view.
Cột ``enabled`` là trạng thái cổng project; ``NavRailMixin._rebuild_nav``
bỏ hẳn những hàng đang đóng (Cowork, GraphRAG) khỏi menu trái cho tới khi
người dùng chọn một project. See nav_subtabs() for the visible-only view.
"""
icons = {self._project_tab_idx: "folder", self._cowork_tab_idx: "chat",
self._co4e_tab_idx: "flow", self._folder_tab_idx: "folder",
@@ -78,8 +77,8 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
def subtab_available(self, index: int) -> bool:
"""False while the project gate is holding this sub-tab shut.
The rail greys those rows out, but that only guards the rail. This lets
every other route ask the same question of the same state.
Rail bỏ hẳn những hàng đó khỏi menu, nhưng đó chỉ chắn được đường vào
qua rail. Hàm này để mọi đường vào khác hỏi cùng một trạng thái.
"""
return bool(0 <= index < self.tabs.count() and self.tabs.isTabVisible(index))
@@ -291,6 +290,28 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
folder_row.addWidget(self._open_btn)
rl.addLayout(folder_row)
# DF-007 — an ALTERNATIVE way to set output_dir: browse OneDrive/
# SharePoint via Graph API and download a local mirror instead of
# picking an already-local folder. output_dir still always ends up a
# real local path (see Project.cloud_source) — nothing downstream
# (run_command/read_file/...) needs to know the difference.
cloud_row = QHBoxLayout()
self._cloud_pick_btn = QPushButton()
self._cloud_pick_btn.setIcon(icon("cloud"))
self._cloud_pick_btn.clicked.connect(self._pick_cloud_folder)
self._cloud_sync_btn = QPushButton()
self._cloud_sync_btn.setIcon(icon("refresh"))
self._cloud_sync_btn.clicked.connect(self._sync_cloud_folder)
self._cloud_sync_btn.hide()
cloud_row.addWidget(self._cloud_pick_btn)
cloud_row.addWidget(self._cloud_sync_btn)
cloud_row.addStretch(1)
rl.addLayout(cloud_row)
self._cloud_badge_lbl = QLabel()
self._cloud_badge_lbl.setWordWrap(True)
self._cloud_badge_lbl.hide()
rl.addWidget(self._cloud_badge_lbl)
rl.addStretch(1) # the drawing floats Lưu project at the bottom
save_row = QHBoxLayout()
self._save_btn = QPushButton()
@@ -476,6 +497,9 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
self._browse_btn.setText(tr("workspace.browse"))
self._browse_btn.setToolTip(tr("workspace.browse_tooltip"))
self._open_btn.setText(tr("workspace.open_folder"))
self._cloud_pick_btn.setText(tr("workspace.cloud_pick"))
self._cloud_sync_btn.setText(tr("workspace.cloud_sync"))
self._refresh_cloud_badge()
self._save_btn.setText(tr("workspace.save"))
self.retranslate_project_rows()
self._proj_collapse_btn.setToolTip(tr("workspace.collapse_projects_tooltip"))
@@ -585,7 +609,12 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
counts = self._project_counts()
self.project_list.blockSignals(True)
self.project_list.clear()
row_to_select = 0
# -1 chứ không phải 0: chưa chọn gì thì KHÔNG tự chọn hộ project đầu
# danh sách. Chọn hộ là mở luôn cổng Cowork/GraphRAG (xem
# _update_tab_visibility) cho một project người dùng chưa hề bấm vào —
# lúc mở app, và cả sau khi xoá project đang mở. Có ``keep`` khớp thì
# vẫn giữ đúng dòng cũ như trước.
row_to_select = -1
for i, p in enumerate(list_projects()):
chats, tasks = counts.get(p.project_id, (0, 0))
# No text on the item: the row widget paints the name, and setting
@@ -675,6 +704,7 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
self.instr_edit.clear()
self.folder_lbl.setText("")
self._del_btn.setEnabled(False)
self._refresh_cloud_badge(project)
# Show Cowork/GraphRAG ONLY when a project is actually selected.
self._update_tab_visibility(project is not None)
# Bind the embedded Cowork/GraphRAG/History to this project's sandbox.
@@ -886,3 +916,105 @@ class WorkspaceTab(ProjectEditingMixin, QWidget):
wd = project.workspace_dir()
wd.mkdir(parents=True, exist_ok=True)
open_folder(str(wd))
def _cloud_token(self):
"""Lấy access token MS365 hiện tại theo cấu hình — raise
``Ms365AuthError`` nếu chưa đăng nhập/hết hạn (gọi picker trước nên
thường đã có sẵn phiên đăng nhập)."""
from ..core.ms365_auth import get_access_token
ms365 = (self.ctx.config.ms365 or {})
return get_access_token(ms365.get("tenant_id", ""), ms365.get("client_id", ""))
def _pick_cloud_folder(self) -> None:
"""DF-007 — duyệt OneDrive/SharePoint qua Graph API, tải một bản
mirror cục bộ xuống rồi dùng bản mirror đó làm output_dir của
project. Xem core/cloud_workspace_sync.py cho giới hạn (một chiều,
thủ công, không đồng bộ liên tục, không xử lý xung đột)."""
from ..core.cloud_workspace_sync import download_folder
from ..core.ms365_auth import Ms365AuthError
from ..core.projects import WORKSPACES_DIR, load_project, save_project
from .cloud_folder_picker_dialog import pick_cloud_folder
pid = self._current_id
project = load_project(pid) if pid else None
if project is None:
return
cloud_source = pick_cloud_folder(self, self.ctx.config)
if not cloud_source:
return
local_dir = WORKSPACES_DIR / project.project_id / "_cloud_mirror"
self._cloud_pick_btn.setEnabled(False)
try:
token = self._cloud_token()
report = download_folder(token, cloud_source, local_dir)
except Ms365AuthError as exc:
QMessageBox.warning(self, tr("workspace.cloud_pick"), str(exc))
return
finally:
self._cloud_pick_btn.setEnabled(True)
project.output_dir = str(local_dir)
project.cloud_source = cloud_source
save_project(project)
self.folder_lbl.setText(str(local_dir))
self._refresh_cloud_badge(project)
self.projects_changed.emit()
if report.errors:
QMessageBox.warning(self, tr("workspace.cloud_pick"),
tr("workspace.cloud_sync_errors", n=len(report.errors)))
self.status_message.emit(tr("workspace.saved", name=project.name))
def _sync_cloud_folder(self) -> None:
"""DF-007 — đẩy thay đổi cục bộ lên cloud rồi tải lại (một chiều mỗi
bước, thủ công, chạy khi bấm nút). Không xoá file 2 phía, không phát
hiện xung đột — xem core/cloud_workspace_sync.py."""
from ..core.cloud_workspace_sync import download_folder, upload_folder
from ..core.ms365_auth import Ms365AuthError
from ..core.projects import load_project
pid = self._current_id
project = load_project(pid) if pid else None
if project is None or not project.cloud_source:
return
self._cloud_sync_btn.setEnabled(False)
try:
token = self._cloud_token()
up_report = upload_folder(token, project.cloud_source, project.workspace_dir())
down_report = download_folder(token, project.cloud_source, project.workspace_dir())
except Ms365AuthError as exc:
QMessageBox.warning(self, tr("workspace.cloud_sync"), str(exc))
return
finally:
self._cloud_sync_btn.setEnabled(True)
lines = [tr("workspace.cloud_sync_result",
up=up_report.transferred, down=down_report.transferred)]
n_errors = len(up_report.errors) + len(down_report.errors)
if n_errors:
lines.append(tr("workspace.cloud_sync_errors", n=n_errors))
n_skipped = len(up_report.skipped_too_large)
if n_skipped:
lines.append(tr("workspace.cloud_sync_skipped", n=n_skipped))
QMessageBox.information(self, tr("workspace.cloud_sync"), "\n".join(lines))
def _refresh_cloud_badge(self, project=None) -> None:
"""Hiện/ẩn badge ☁ + nút Đồng bộ theo project đang mở có phải một
mirror cloud hay không (``project.cloud_source``)."""
if project is None:
from ..core.projects import load_project
project = load_project(self._current_id) if self._current_id else None
cloud_source = project.cloud_source if project is not None else None
if not cloud_source:
self._cloud_badge_lbl.hide()
self._cloud_sync_btn.hide()
return
if cloud_source.get("provider") == "sharepoint":
text = tr("workspace.cloud_badge_sharepoint",
site=cloud_source.get("site_name", ""),
path=cloud_source.get("remote_path", "") or "/")
else:
text = tr("workspace.cloud_badge_onedrive",
path=cloud_source.get("remote_path", "") or "/")
self._cloud_badge_lbl.setText(text)
self._cloud_badge_lbl.show()
self._cloud_sync_btn.show()