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
130 changed files with 10284 additions and 1240 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. starts directly with full admin access.
""" """
__version__ = "2.26.0" __version__ = "0.0.1"
# Internal/technical name — config dir (~/.cowork_local), QSettings org keys, # Internal/technical name — config dir (~/.cowork_local), QSettings org keys,
# packaging scripts and docs still use this; do NOT rebrand it. # packaging scripts and docs still use this; do NOT rebrand it.
APP_NAME = "Cowork Local" APP_NAME = "Cowork Local"
+62 -8
View File
@@ -21,6 +21,7 @@ Anti-pattern mà bộ này cố tình tránh (mục 10 của tài liệu trainin
| Không có Output Contract | Mọi output đi qua template trong `output/` | | Không có Output Contract | Mọi output đi qua template trong `output/` |
| Không có Quality Gate | Mỗi role có Quality Gate riêng + `checklist/` dùng chung | | Không có Quality Gate | Mỗi role có Quality Gate riêng + `checklist/` dùng chung |
| Không có example | `examples/good_fix.md` và `examples/bad_fix.md` | | Không có example | `examples/good_fix.md` và `examples/bad_fix.md` |
| Effort cố định bất kể lỗi to nhỏ | `roles/0_fix_dispatcher.md` chấm tier trước, lỗi 4px chạy 0 agent |
Sáu role **không** bị tách thành 7 file nhỏ mỗi role (role/task/process/...). Lý do: Sáu role **không** bị tách thành 7 file nhỏ mỗi role (role/task/process/...). Lý do:
phần bị lặp giữa các role chính là guardrail, knowledge và checklist — chúng đã được phần bị lặp giữa các role chính là guardrail, knowledge và checklist — chúng đã được
@@ -47,7 +48,8 @@ agent/
│ ├─ qt_pitfalls.md ← 20 nguyên nhân gốc hay gặp của bug UI PySide6 │ ├─ qt_pitfalls.md ← 20 nguyên nhân gốc hay gặp của bug UI PySide6
│ ├─ secrets_and_config.md ← SecretStore, schema migration, bẫy .get() trên config merge │ ├─ secrets_and_config.md ← SecretStore, schema migration, bẫy .get() trên config merge
│ └─ quality_gates.md ← CASAN gate, lệnh chạy, test headless │ └─ quality_gates.md ← CASAN gate, lệnh chạy, test headless
├─ roles/ ← 7 agent chuyên biệt ├─ roles/ ← 1 hub + 7 agent chuyên biệt
│ ├─ 0_fix_dispatcher.md ← HUB: chấm tier T0/T1/T2/T3, chọn lane, tách defect
│ ├─ 1_ui_bug_triage.md │ ├─ 1_ui_bug_triage.md
│ ├─ 2_ui_visual_fixer.md │ ├─ 2_ui_visual_fixer.md
│ ├─ 3_ux_flow_fixer.md │ ├─ 3_ux_flow_fixer.md
@@ -55,14 +57,17 @@ agent/
│ ├─ 5_fix_implementer.md │ ├─ 5_fix_implementer.md
│ ├─ 6_regression_reviewer.md │ ├─ 6_regression_reviewer.md
│ └─ 7_security_defect_fixer.md │ └─ 7_security_defect_fixer.md
├─ commands/
│ └─ fix.md ← nguồn của slash command /fix (điểm vào của hub)
├─ workflow/ ├─ workflow/
│ ├─ intake_to_fix.md ← pipeline end-to-end, ai làm gì ở bước nào │ ├─ intake_to_fix.md ← pipeline end-to-end, 4 lane theo tier
│ └─ handoff_contract.md ← envelope truyền giữa các agent │ └─ handoff_contract.md ← envelope truyền giữa các agent
├─ checklist/ ├─ checklist/
│ ├─ ui_review.md │ ├─ ui_review.md
│ ├─ ux_review.md │ ├─ ux_review.md
│ └─ pr_readiness.md │ └─ pr_readiness.md
├─ output/ ├─ output/
│ ├─ dispatch_plan.md ← template điều phối (output của Hub)
│ ├─ defect_record.md ← template hồ sơ lỗi (output của Triage) │ ├─ defect_record.md ← template hồ sơ lỗi (output của Triage)
│ ├─ fix_plan.md ← template phương án sửa (output của Fixer) │ ├─ fix_plan.md ← template phương án sửa (output của Fixer)
│ ├─ fix_report.md ← template báo cáo sau khi sửa (output của Implementer) │ ├─ fix_report.md ← template báo cáo sau khi sửa (output của Implementer)
@@ -74,10 +79,11 @@ agent/
--- ---
## 3. Bảy agent và khi nào dùng ## 3. Một hub + bảy agent, và khi nào dùng
| # | Agent | Pattern | Nhận vào | Trả ra | | # | Agent | Pattern | Nhận vào | Trả ra |
|---|---|---|---|---| |---|---|---|---|---|
| **0** | **Fix Dispatcher** (hub) | Router | Phản ánh thô của người dùng | `dispatch_plan.md` — tier + lane + tách defect |
| 1 | **UI Bug Triage** | Reviewer | Lời kể lộn xộn của user, ảnh chụp màn hình, log | `defect_record.md` + phân loại + route | | 1 | **UI Bug Triage** | Reviewer | Lời kể lộn xộn của user, ảnh chụp màn hình, log | `defect_record.md` + phân loại + route |
| 2 | **UI Visual Fixer** | Generator | defect_record (loại `visual`) | `fix_plan.md` — layout/QSS/theme/icon/DPI | | 2 | **UI Visual Fixer** | Generator | defect_record (loại `visual`) | `fix_plan.md` — layout/QSS/theme/icon/DPI |
| 3 | **UX Flow Fixer** | Generator | defect_record (loại `flow`) | `fix_plan.md` — luồng, trạng thái, phản hồi | | 3 | **UX Flow Fixer** | Generator | defect_record (loại `flow`) | `fix_plan.md` — luồng, trạng thái, phản hồi |
@@ -86,19 +92,36 @@ agent/
| 6 | **Regression Reviewer** | Reviewer | Patch + fix_report | Verdict PASS/FAIL + `pr_body.md` | | 6 | **Regression Reviewer** | Reviewer | Patch + fix_report | Verdict PASS/FAIL + `pr_body.md` |
| 7 | **Security Defect Fixer** | Generator | defect_record (loại `security`) | `fix_plan.md` — credential, secret, migration | | 7 | **Security Defect Fixer** | Generator | defect_record (loại `security`) | `fix_plan.md` — credential, secret, migration |
Đây là **Multi-Agent Pattern**: `Triage (Planner) → Specialist → Implementer (Executor) Đây là **Multi-Agent Pattern**: `Dispatcher (Router) → Triage (Planner) → Specialist →
→ Reviewer`. Không bỏ bước. Đặc biệt không bỏ bước 1: 80% bug UI báo lên là mô tả Implementer (Executor) → Reviewer`.
triệu chứng, không phải nguyên nhân.
**Số bước thực chạy do agent 0 quyết định, không phải mặc định 5.** Bộ v1.2 chạy đủ pipeline
cho mọi lỗi, kể cả đổi một giá trị 4px — đó là lý do agent 0 ra đời. Bốn lane:
| Tier | Lỗi kiểu gì | Lane | Gọi agent |
|---|---|---|---|
| **T0** | Đổi số đo hiển thị, sai chính tả chuỗi có key sẵn, đổi token màu có sẵn | DIRECT | **0 lần** — hub sửa luôn + 4 cổng máy |
| **T1** | Nguyên nhân gốc đã rõ kèm `file:line`, 1 màn, ≤ 3 file, ≤ 40 LOC | SOLO | 1 lần |
| **T2** | Nguyên nhân chưa rõ nhưng đã khoanh 1 màn; chạm QSS/token/i18n dùng chung | PAIR | 3 lần |
| **T3** | Mô tả thuần triệu chứng, không tái hiện được, nhiều category, > 150 LOC | FULL | 4–5 lần |
Bước 1 vẫn **không** được bỏ ở T3 — 80% bug UI báo lên là mô tả triệu chứng, không phải
nguyên nhân. Ở T1/T2, phần triage do hub tự làm trong `dispatch_plan`, và chỉ hợp lệ khi
phản ánh đã tự chỉ ra màn hình + triệu chứng cụ thể. Bước 6 chỉ được bỏ ở T0/T1, và phải
nêu rõ cổng nào thay thế.
Agent 7 là specialist thứ tư, ngang hàng 2/3/4 trong pipeline, nhưng khác ở hai điểm: nó Agent 7 là specialist thứ tư, ngang hàng 2/3/4 trong pipeline, nhưng khác ở hai điểm: nó
được phép chạm `config.py`, `infrastructure/`, `core/` (ba role kia bị chặn ở tầng được phép chạm `config.py`, `infrastructure/`, `core/` (ba role kia bị chặn ở tầng
presentation), và nó **không được tự quyết chính sách bảo mật** — bốn câu hỏi bắt buộc trả presentation), và nó **không được tự quyết chính sách bảo mật** — bốn câu hỏi bắt buộc trả
về cho Cowork Team. về cho Cowork Team.
### Routing rule (Triage quyết định) ### Routing rule (Hub chấm tier → Triage chọn specialist)
```text ```text
Người dùng báo lỗi Người dùng báo lỗi
│
├─ agent 0 tách thành N defect_id, chấm tier từng cái
│ (≤ 5 lệnh đọc/grep, 0 subagent; hết mà chưa chấm được → T2)
│ │
├─ "nhìn sai / lệch / mất chữ / màu lạ / bị che" → 2. UI Visual Fixer ├─ "nhìn sai / lệch / mất chữ / màu lạ / bị che" → 2. UI Visual Fixer
├─ "bấm không ăn / không biết đang chạy / mất dữ liệu" → 3. UX Flow Fixer ├─ "bấm không ăn / không biết đang chạy / mất dữ liệu" → 3. UX Flow Fixer
@@ -108,12 +131,36 @@ Người dùng báo lỗi
Trả về, mở issue type:bug thường. Trả về, mở issue type:bug thường.
Nhóm `security` THẮNG mọi nhóm khác: lỗi vừa lệch layout vừa lộ credential thì đi 7 trước. Nhóm `security` THẮNG mọi nhóm khác: lỗi vừa lệch layout vừa lộ credential thì đi 7 trước.
Tín hiệu bảo mật cũng ép tier lên **T3-SEC** bất kể diff nhỏ cỡ nào — một dòng `==` so
mật khẩu không bao giờ là T0.
``` ```
Tier chỉ đi **lên**. FAIL ở bước 6 → tier +1 rồi chạy lại, không sửa lại ở nguyên tier cũ.
--- ---
## 4. Cách dùng ## 4. Cách dùng
### 4.0 Điểm vào (khuyến nghị)
Cài một lần cho mỗi máy — `.claude/` nằm trong `.gitignore`, nên nó **không** theo
clone; `agent/` mới là bản gốc được version:
```bash
mkdir -p .claude/agents .claude/commands
cp agent/roles/[1-7]_*.md .claude/agents/
cp agent/commands/fix.md .claude/commands/
```
Rồi:
```text
/fix màn Folder kéo to ra thì mất cây thư mục bên trái
```
Hub sẽ chấm tier, in `dispatch_plan`, rồi tự chạy đúng lane. Chỉ gọi trực tiếp role 1–7
khi đã biết chắc tier.
### 4.1 Dùng thủ công (mọi trợ lý AI) ### 4.1 Dùng thủ công (mọi trợ lý AI)
Nạp theo đúng thứ tự này rồi dán bug report của user vào: Nạp theo đúng thứ tự này rồi dán bug report của user vào:
@@ -122,6 +169,7 @@ Nạp theo đúng thứ tự này rồi dán bug report của user vào:
agent/system/guardrail.md agent/system/guardrail.md
agent/system/security.md agent/system/security.md
agent/system/response_policy.md agent/system/response_policy.md
agent/roles/0_fix_dispatcher.md ← luôn nạp trước, để biết cần chạy tới đâu
agent/roles/<role đang dùng>.md agent/roles/<role đang dùng>.md
+ các file knowledge/ mà role đó liệt kê ở mục "KNOWLEDGE" + các file knowledge/ mà role đó liệt kê ở mục "KNOWLEDGE"
``` ```
@@ -133,9 +181,13 @@ subagent, copy sang `.claude/agents/`:
```bash ```bash
mkdir -p .claude/agents mkdir -p .claude/agents
cp agent/roles/*.md .claude/agents/ cp agent/roles/[1-7]_*.md .claude/agents/
``` ```
`0_fix_dispatcher.md` **không** copy vào `.claude/agents/`: hub cần quyền gọi agent khác,
mà subagent trong Claude Code không gọi được subagent. Hub chạy ở session chính, qua
`/fix` (`.claude/commands/fix.md`).
Sau đó gọi bằng tên: `ui-bug-triage`, `ui-visual-fixer`, `ux-flow-fixer`, Sau đó gọi bằng tên: `ui-bug-triage`, `ui-visual-fixer`, `ux-flow-fixer`,
`i18n-a11y-fixer`, `fix-implementer`, `regression-reviewer`, `security-defect-fixer`. `i18n-a11y-fixer`, `fix-implementer`, `regression-reviewer`, `security-defect-fixer`.
@@ -154,4 +206,6 @@ trong commit message — instruction cũng là code.
|---|---|---| |---|---|---|
| 1.0 | 2026-09-07 | Bản đầu: 6 role, 6 knowledge module, 4 output contract | | 1.0 | 2026-09-07 | Bản đầu: 6 role, 6 knowledge module, 4 output contract |
| 1.1 | 2026-09-07 | Thêm role 7 `security-defect-fixer` + `knowledge/secrets_and_config.md`. Lý do: bộ v1.0 chỉ phủ UI/UX, nên credential hardcode phát hiện qua màn Settings bị rơi vào `not-ui` và không ai nhận | | 1.1 | 2026-09-07 | Thêm role 7 `security-defect-fixer` + `knowledge/secrets_and_config.md`. Lý do: bộ v1.0 chỉ phủ UI/UX, nên credential hardcode phát hiện qua màn Settings bị rơi vào `not-ui` và không ai nhận |
| 1.4 | 2026-09-08 | Nạp bài học từ lượt audit i18n toàn app. `knowledge/i18n_rules.md` §2.0 (`bind_*` là cách mặc định cho chuỗi tĩnh, `bind_dynamic` cho chữ theo trạng thái, không bind dữ liệu), §"Cách TÌM ra hết các chỗ bị lỗi" (grep chuỗi tiếng Việt ra 962 dòng mà **không** dòng nào là lỗi thật; phép đo đúng là thay `tr()` bằng chuỗi mốc trên `MainWindow` thật), và 3 mục checklist mới. Lý do: bộ v1.3 không có cách nào phát hiện lỗi "chữ không được áp lại" — nó không để lại dấu vết nào trong source |
| 1.3 | 2026-09-08 | Thêm hub `0_fix_dispatcher` + `output/dispatch_plan.md` + `/fix`. Lý do: bộ v1.2 không có tầng điều phối, nên **mọi** lỗi đều kéo cả pipeline 4–5 agent — kể cả nới một `setMinimumWidth` lên 232px. Bổ sung 4 lane theo tier, danh sách đóng T0 (6 loại + 9 disqualifier), 4 cổng máy thay reviewer ở T0, luật escalate một chiều, và luật tách một phản ánh thành nhiều `defect_id` chấm tier riêng |
| 1.2 | 2026-09-07 | Nạp bài học từ lần chạy thật đầu tiên (`SEC-20260907-01`). Bản vá của bước 5 mang một blocker mà **không mục nào trong bộ v1.1 bắt được** — reviewer tìm ra bằng tay. Bổ sung: `secrets_and_config.md` §9 (chặn rỗng, `compare_digest` + ASCII, và luật "API an toàn hơn thường có miền đầu vào hẹp hơn"); `6_regression_reviewer.md` Bước 2.1 (ràng buộc miền đầu vào) và 4.1 (test rỗng ruột); `5_fix_implementer.md` + `quality_gates.md` (baseline bằng `comm -13` trên tên test, guard `git add`, và thực tế suite vốn đã đỏ 11+66); `bad_fix.md` ca 11-12 — hai ví dụ **có thật** đầu tiên trong file | | 1.2 | 2026-09-07 | Nạp bài học từ lần chạy thật đầu tiên (`SEC-20260907-01`). Bản vá của bước 5 mang một blocker mà **không mục nào trong bộ v1.1 bắt được** — reviewer tìm ra bằng tay. Bổ sung: `secrets_and_config.md` §9 (chặn rỗng, `compare_digest` + ASCII, và luật "API an toàn hơn thường có miền đầu vào hẹp hơn"); `6_regression_reviewer.md` Bước 2.1 (ràng buộc miền đầu vào) và 4.1 (test rỗng ruột); `5_fix_implementer.md` + `quality_gates.md` (baseline bằng `comm -13` trên tên test, guard `git add`, và thực tế suite vốn đã đỏ 11+66); `bad_fix.md` ca 11-12 — hai ví dụ **có thật** đầu tiên trong file |
+144 -39
View File
@@ -1,53 +1,158 @@
# Checklist sẵn sàng tạo PR # Checklist sẵn sàng tạo PR
Dùng bởi `fix-implementer` (bước 9) và `regression-reviewer` (bước 8). Checklist này được sử dụng bởi:
Bám theo `.gitea/PULL_REQUEST_TEMPLATE.md` và `docs/governance/definition-of-done.md`.
## A. Cổng chất lượng * `fix-implementer` — kiểm tra ở bước 9.
* `regression-reviewer` — kiểm tra ở bước 8.
- [ ] `python scripts/run_quality_gate.py` — xanh cả 5 cổng, **có dán output thật**. Tham chiếu:
- [ ] Gate C: `domain/`/`application/` không import PySide6/PyQt/`ui`/`app`.
- [ ] Gate A: không secret/plaintext mới.
- [ ] Gate S: không file nào > 400 LOC.
- [ ] Gate O: không module mồ côi (file mới đã được import trong cùng commit).
- [ ] Gate A/N: pytest xanh; test vốn đỏ từ trước được ghi riêng.
## B. Kiểm chứng * `.gitea/PULL_REQUEST_TEMPLATE.md`
* `docs/governance/definition-of-done.md`
- [ ] Test regression tồn tại và **đỏ trước / xanh sau**. ---
- [ ] Test chạy được headless (`QT_QPA_PLATFORM=offscreen`).
- [ ] Đã kiểm bằng mắt ở dark + light — hoặc ghi rõ "chưa kiểm chứng bằng mắt" kèm lý do.
- [ ] Đã kiểm ở các ngôn ngữ liên quan.
## C. Phạm vi & lịch sử ## A. Kiểm tra chất lượng
- [ ] Một PR = một thay đổi logic. Không refactor lẫn vào. * [ ] Chạy `python scripts/run_quality_gate.py`.
- [ ] Không đổi format/indent toàn file; diff đọc được. Cả **5 quality gate đều phải PASS** và phải ghi lại **output thực tế**.
- [ ] Nhánh riêng, không commit thẳng `main`.
- [ ] Commit message nêu nguyên nhân gốc + `file:line` + issue.
- [ ] Không commit `.env`, `config.json` local, dữ liệu dưới `.cowork_local/`, `.venv`.
## D. Bảo mật * [ ] **Gate C:** Các thư mục `domain/` và `application/` không được import:
- `PySide6`
- `PyQt`
- `ui`
- `app`
- [ ] Không secret/PII/đường dẫn cá nhân trong code, test fixture, commit message, PR body. * [ ] **Gate A:** Không tạo thêm secret hoặc thông tin nhạy cảm dạng plaintext.
- [ ] Ảnh chụp màn hình đính kèm đã được redact.
- [ ] Nếu chạm permission / credential / MCP write-exec / sandbox / network / TLS /
isolation / model routing / xoá dữ liệu → đánh dấu `security-review: required` và ghi
rõ trong PR rằng **CI xanh không đủ để merge**.
## E. Nội dung PR * [ ] **Gate S:** Không có file nào vượt quá **400 dòng code (LOC)**.
- [ ] Summary nói **tại sao**, không chỉ **cái gì**. * [ ] **Gate O:** Không có file/module mới bị bỏ quên.
- [ ] Change Type đã tick. File Python mới phải được sử dụng/import trong cùng thay đổi.
- [ ] Scope: nêu rõ cả phần **cố ý không** làm.
- [ ] Validation: có lệnh và output thật.
- [ ] Security Impact: đã điền, kể cả khi là "không có".
- [ ] Compatibility: đã tick.
- [ ] Reviewer Notes: chỉ ra chỗ cần soi kỹ nhất.
- [ ] Tài liệu (`docs/`, ảnh `docs/screens/`) đã cập nhật nếu cần.
## F. Ranh giới * [ ] **Gate A/N:** Test phải PASS.
Nếu đã có test FAIL từ trước thì phải ghi rõ đó là **lỗi có sẵn**, không phải lỗi do bản sửa này gây ra.
- [ ] Agent **không** tự merge, **không** tự đóng issue. ---
- [ ] Nếu là đóng góp của FSG AI Core: hiểu rằng chỉ "Done" khi PR đã merge vào Cowork Local,
kèm đủ core issue reference, PR, evidence, reviewer phía Cowork, merge reference. ## B. Kiểm tra bản sửa
* [ ] Có **regression test** cho lỗi đã sửa.
* [ ] Regression test phải chứng minh được:
- **Trước khi sửa:** test FAIL.
- **Sau khi sửa:** test PASS.
* [ ] Test chạy được ở chế độ headless:
`QT_QPA_PLATFORM=offscreen`
* [ ] Nếu thay đổi liên quan đến UI:
- Đã kiểm tra giao diện ở **Dark Mode**.
- Đã kiểm tra giao diện ở **Light Mode**.
- Nếu chưa thể kiểm tra bằng mắt, phải ghi rõ:
**"Chưa kiểm chứng bằng mắt"** và nêu lý do.
* [ ] Nếu thay đổi liên quan đến ngôn ngữ:
đã kiểm tra các ngôn ngữ bị ảnh hưởng.
---
## C. Kiểm tra phạm vi thay đổi và Git
* [ ] Một PR chỉ giải quyết **một thay đổi logic chính**.
Không đưa refactor không liên quan vào cùng PR.
* [ ] Không tự ý format hoặc thay đổi indent của toàn bộ file.
Diff phải rõ ràng và dễ review.
* [ ] Làm việc trên **branch riêng**.
Không commit trực tiếp vào `main`.
* [ ] Commit message phải nêu:
- Nguyên nhân gốc của lỗi.
- Vị trí code liên quan (`file:line`).
- Issue liên quan.
* [ ] Không commit các file/dữ liệu sau:
- `.env`
- `config.json` local
- `.cowork_local/`
- `.venv/`
---
## D. Kiểm tra bảo mật
* [ ] Không có các thông tin sau trong code, test fixture, commit message hoặc PR body:
- Secret
- PII/thông tin cá nhân
- Đường dẫn chứa thông tin cá nhân trên máy local
* [ ] Nếu có ảnh chụp màn hình trong PR:
đã che (redact) toàn bộ thông tin nhạy cảm trước khi đính kèm.
* [ ] Nếu thay đổi liên quan đến một trong các nội dung sau:
```
- Permission/quyền truy cập
- Credential/thông tin xác thực
- MCP write/exec
- Sandbox
- Network
- TLS
- Isolation
- Model routing
- Xóa dữ liệu
thì phải:
1. Đặt `security-review: required`.
2. Ghi rõ trong PR rằng:
**"CI xanh không có nghĩa là có thể merge ngay."**
3. Chờ security review theo quy trình trước khi merge.
```
---
## E. Kiểm tra nội dung PR
* [ ] **Summary** phải giải thích **tại sao cần sửa**, không chỉ mô tả đã sửa cái gì.
* [ ] Đã chọn **Change Type** phù hợp.
* [ ] **Scope** phải ghi rõ:
- Những gì đã thay đổi.
- Những gì **cố ý không thay đổi**.
* [ ] **Validation** phải ghi:
- Lệnh đã chạy.
- Kết quả thực tế/output.
* [ ] **Security Impact** phải được điền.
Nếu không ảnh hưởng bảo mật, ghi rõ **"Không có"**.
* [ ] Đã chọn **Compatibility** phù hợp.
* [ ] **Reviewer Notes** phải chỉ ra những phần reviewer cần kiểm tra kỹ nhất.
* [ ] Đã cập nhật tài liệu nếu cần:
- `docs/`
- Ảnh màn hình trong `docs/screens/`
---
## F. Giới hạn quyền của Agent
* [ ] Agent **không được tự merge PR**.
* [ ] Agent **không được tự đóng issue**.
* [ ] Nếu đây là đóng góp từ **FSG AI Core**, cần hiểu rằng trạng thái **"Done"** chỉ được xác nhận khi PR đã thực sự được merge vào Cowork Local và có đầy đủ:
```
- Core issue reference
- PR reference
- Evidence
- Reviewer phía Cowork
- Merge reference
```
+190 -36
View File
@@ -1,49 +1,203 @@
# Checklist review bản vá UI (visual) # Checklist review bản vá UI (Visual)
Dùng bởi `ui-visual-fixer` (bước 7) và `regression-reviewer` (bước 5). Checklist này được sử dụng bởi:
## A. Đúng file * `ui-visual-fixer` — kiểm tra ở bước 7.
* `regression-reviewer` — kiểm tra ở bước 5.
- [ ] Đã `grep` cả `ui/` và `presentation/`; file được sửa là file thực sự import vào runtime. Mục tiêu: đảm bảo bản vá UI sửa đúng nguyên nhân, không phá theme, layout, icon hoặc vòng đời của giao diện.
- [ ] Widget này không có bản trùng tên ở thư mục còn lại.
## B. Màu & theme ---
- [ ] Không hex literal (`#rrggbb`), không tên màu (`"red"`) ngoài `theme/`. ## A. Kiểm tra đúng file
- [ ] Không `setStyleSheet` cục bộ mới; style đi qua `objectName` + `theme/qss.py`.
- [ ] Token mới có ở **cả** `DARK` và `LIGHT`.
- [ ] Chữ trên nền đặc dùng `accent_solid`, không dùng `accent`.
- [ ] Bậc bề mặt đúng ngữ nghĩa: `bg` / `surface` / `surface_raised` / `overlay` / `sunken`.
- [ ] Contrast ≥ 4.5:1 cho body text và chữ trên nút đặc, ở cả hai theme.
- [ ] Không thêm gradient/glow (trái ràng buộc thiết kế).
- [ ] Nav rail vẫn tối hơn vùng nội dung.
- [ ] Không trả bốn giá trị đã nhích lên WCAG AA về giá trị VS Code gốc.
- [ ] Nếu chạm `_TEMPLATE`: đã liệt kê phạm vi ảnh hưởng toàn app.
## C. Layout & kích thước * [ ] Đã tìm kiếm trong **cả `ui/` và `presentation/`** để xác định file thực sự được ứng dụng sử dụng khi chạy.
- [ ] Không thêm `setFixedWidth` / `setFixedSize` / `setFixedHeight` mới. * [ ] Đã kiểm tra xem widget có file/bản triển khai trùng tên ở thư mục còn lại hay không.
- [ ] Stretch factor / size policy được đặt tường minh.
- [ ] `QScrollArea` có `setWidgetResizable(True)`.
- [ ] Margin/spacing của layout lồng nhau không cộng dồn ngoài ý muốn.
- [ ] Còn đúng ở cửa sổ nhỏ nhất **và** maximize.
- [ ] Còn đúng ở scale 125% / 150% nếu bản vá chạm kích thước.
## D. Icon & vẽ tay * [ ] Nếu có nhiều file cùng chức năng, đã xác định rõ **file nào thực sự được import và chạy**.
- [ ] Icon lấy qua `ui/icons.py::icon`, không load file trực tiếp. ---
- [ ] `paintEvent` đọc màu qua `current_palette()`, không đọc lại config.
- [ ] Dùng `update()`, không `repaint()` trong vòng lặp.
- [ ] `QPainter` có `end()`; nền được xoá đúng cách.
## E. Vòng đời ## B. Kiểm tra màu sắc và Theme
- [ ] Bản vá còn đúng khi đổi theme **trước** rồi mới mở màn dựng lười (P07). * [ ] Không thêm mã màu trực tiếp như `#rrggbb` hoặc tên màu như `"red"` bên ngoài thư mục `theme/`.
- [ ] `setProperty` để đổi style động có kèm `unpolish`/`polish`.
- [ ] Không `connect()` lặp lại trong hàm được gọi nhiều lần.
## F. Bằng chứng * [ ] Không thêm `setStyleSheet()` trực tiếp vào widget.
Style phải được quản lý thông qua:
- [ ] Đã đối chiếu `docs/screens/<slug>-dark.png` và `<slug>-light.png`. ```
- [ ] Ảnh trong `docs/screens/` cần cập nhật thì đã nêu. `objectName` → `theme/qss.py`
- [ ] Có test regression chạy headless, đỏ-trước-xanh-sau. ```
* [ ] Nếu thêm token màu mới, token đó phải được khai báo cho **cả `DARK` và `LIGHT`**.
* [ ] Khi đặt chữ trên nền màu đặc, dùng `accent_solid`.
Không dùng `accent` cho trường hợp này.
* [ ] Dùng đúng loại màu nền theo mục đích:
```
- `bg` — nền chính.
- `surface` — bề mặt thông thường.
- `surface_raised` — bề mặt nổi.
- `overlay` — lớp phủ.
- `sunken` — khu vực chìm.
```
* [ ] Contrast của chữ đạt tối thiểu **4.5:1** đối với:
- Body text.
- Chữ trên nút có nền đặc.
- Cả Dark Mode và Light Mode.
* [ ] Không thêm:
- Gradient.
- Glow.
```
Đây là các kiểu không phù hợp với design constraint hiện tại.
```
* [ ] `Nav rail` vẫn **tối hơn khu vực nội dung**.
Đây là thiết kế có chủ ý, không tự ý làm sáng lên.
* [ ] Không khôi phục các giá trị màu cũ theo VS Code nếu các giá trị hiện tại đã được điều chỉnh để đạt WCAG AA.
* [ ] Nếu thay đổi `_TEMPLATE`:
đã đánh giá và ghi rõ **phạm vi ảnh hưởng trên toàn ứng dụng** vì `_TEMPLATE` có thể ảnh hưởng nhiều màn hình.
---
## C. Kiểm tra Layout và kích thước
* [ ] Không thêm mới:
```
- `setFixedWidth()`
- `setFixedHeight()`
- `setFixedSize()`
để che hoặc né lỗi layout.
```
* [ ] `stretch factor` và `size policy` được thiết lập rõ ràng khi cần.
* [ ] Nếu sử dụng `QScrollArea`, phải có:
```
`setWidgetResizable(True)`
```
* [ ] Kiểm tra margin và spacing của các layout lồng nhau.
Không được để chúng cộng dồn khiến UI bị lệch hoặc quá rộng.
* [ ] UI vẫn hiển thị đúng ở:
- Kích thước cửa sổ nhỏ nhất.
- Cửa sổ maximize.
* [ ] Nếu bản vá liên quan đến kích thước, phải kiểm tra thêm ở:
- Scale 125%.
- Scale 150%.
---
## D. Kiểm tra Icon và Custom Painting
* [ ] Icon phải được lấy thông qua:
```
`ui/icons.py::icon`
Không tự load file icon trực tiếp.
```
* [ ] Trong `paintEvent()`, màu sắc phải lấy từ:
```
`current_palette()`
Không đọc lại màu trực tiếp từ config.
```
* [ ] Trong các vòng lặp hoặc thao tác cập nhật UI, dùng:
```
`update()`
Không dùng `repaint()` nếu không thực sự cần thiết.
```
* [ ] `QPainter` được kết thúc đúng cách bằng `end()` khi sử dụng thủ công.
* [ ] Nền của khu vực custom painting được xử lý/xóa đúng cách, không để lại hình ảnh hoặc pixel cũ.
---
## E. Kiểm tra vòng đời UI
* [ ] UI vẫn hoạt động đúng nếu người dùng:
```
1. Đổi theme trước.
2. Sau đó mới mở màn hình được tạo theo kiểu lazy.
Đặc biệt kiểm tra lỗi **P07**.
```
* [ ] Nếu dùng `setProperty()` để thay đổi style động:
phải gọi `unpolish()` và `polish()` khi cần để QSS được áp dụng lại.
* [ ] Không gọi `connect()` nhiều lần trong một hàm có thể được gọi nhiều lần.
* [ ] Không tạo signal/slot bị kết nối lặp, gây ra:
- Event chạy nhiều lần.
- UI cập nhật nhiều lần.
- Memory leak hoặc hành vi bất thường.
---
## F. Kiểm tra bằng chứng
* [ ] Đã đối chiếu với screenshot trong:
```
`docs/screens/<slug>-dark.png`
và
`docs/screens/<slug>-light.png`
```
* [ ] Nếu bản vá làm thay đổi giao diện, đã xác định screenshot nào cần cập nhật.
* [ ] Nếu cần cập nhật screenshot trong `docs/screens/`, phải ghi rõ trong phạm vi thay đổi.
* [ ] Có regression test cho lỗi đã sửa.
* [ ] Regression test chạy được ở chế độ headless:
```
`QT_QPA_PLATFORM=offscreen`
```
* [ ] Regression test chứng minh được:
```
**Trước khi sửa → FAIL**
**Sau khi sửa → PASS**
```
---
## Kết luận
Chỉ đánh giá bản vá là **PASS** khi:
1. Sửa đúng file thực sự chạy.
2. Không phá theme hoặc layout hiện có.
3. Không dùng workaround để che lỗi.
4. Không tạo regression.
5. Có regression test phù hợp.
6. Có đủ bằng chứng kiểm chứng.
7. Các vấn đề liên quan đến security hoặc product decision đã được route đúng agent/người phụ trách.
+198 -34
View File
@@ -1,48 +1,212 @@
# Checklist review bản vá UX (flow) # Checklist review bản vá UX (Flow)
Dùng bởi `ux-flow-fixer` (bước 8) và `regression-reviewer`. Checklist này được sử dụng bởi:
## A. Bốn trạng thái * `ux-flow-fixer` — kiểm tra ở bước 8.
* `regression-reviewer` — kiểm tra trong quá trình review bản vá.
Cho mỗi view có dữ liệu bất đồng bộ: Mục tiêu: đảm bảo người dùng luôn biết **hệ thống đang làm gì, chuyện gì xảy ra và cần làm gì tiếp theo**, đồng thời không bị mất dữ liệu.
- [ ] **Rỗng** — hiện thông điệp có nghĩa, nói được bước tiếp theo (không phải màn trắng). ---
- [ ] **Đang tải** — có dấu hiệu chuyển động; nút bị vô hiệu hoá để chống bấm đúp.
- [ ] **Lỗi** — nói *cái gì hỏng* và *làm gì tiếp*; có đường thử lại; không in nguyên exception.
- [ ] **Thành công** — có xác nhận rõ; có undo nếu hành động khó đảo ngược.
## B. An toàn dữ liệu ## A. Kiểm tra 4 trạng thái chính
- [ ] Ô nhập dài (instruction, composer, node property, AI Edit) không mất nội dung khi Đối với mỗi màn hình có dữ liệu hoặc thao tác chạy bất đồng bộ, phải kiểm tra đủ 4 trạng thái:
chuyển tab / đóng dialog / đổi project.
- [ ] Có dirty-state; `closeEvent` chặn khi còn thay đổi chưa lưu.
- [ ] Hành động phá huỷ (xoá project/task, ghi đè file) có xác nhận.
- [ ] Xác nhận nêu rõ **cái gì** sẽ mất, không phải "Bạn có chắc không?".
- [ ] Nút phá huỷ **không** phải default button, **không** nhận Enter.
## C. Phản hồi theo thời gian ### 1. Trạng thái Rỗng (Empty)
- [ ] 100ms-1s: đổi con trỏ hoặc vô hiệu hoá nút. * [ ] Khi chưa có dữ liệu, màn hình phải hiển thị thông báo có ý nghĩa.
- [ ] 1s-10s: chỉ báo tiến trình rõ ràng.
- [ ] \>10s: có tiến trình, **huỷ được**, không chặn phần còn lại của UI.
- [ ] Việc nặng chạy ở service `application/`, không ở GUI thread.
- [ ] Bấm hai lần không chạy hai lần (kiểm `connect()` trùng — P10).
## D. Khám phá được * [ ] Thông báo phải cho người dùng biết **cần làm gì tiếp theo**.
- [ ] Mọi nút icon-only có tooltip (nav rail thu gọn, toolbar Co4E, top bar). * [ ] Không để màn hình trắng khiến người dùng không biết chuyện gì đang xảy ra.
- [ ] Nút bị vô hiệu hoá nói được **lý do** (mẫu đúng: `app.nav.needs_project`).
- [ ] Chức năng chính không bị chôn sau menu chuột phải mà không có lối vào khác.
- [ ] Thứ tự control khớp thứ tự người dùng thực hiện.
## E. Nhất quán ### 2. Trạng thái Đang tải (Loading)
- [ ] Cùng một hành động dùng cùng một từ trên mọi màn (không chỗ "Lưu" chỗ "Cập nhật"). * [ ] Có dấu hiệu rõ ràng cho biết hệ thống đang xử lý, ví dụ loading indicator.
- [ ] Vị trí nút chính/phụ giống các dialog khác.
- [ ] Chuỗi mới đi qua `tr()` với đủ `en`/`ja`/`vi`.
## F. Phạm vi * [ ] Các nút có thể gây chạy lại cùng một thao tác được vô hiệu hóa trong lúc đang xử lý.
- [ ] Bản vá chọn mức can thiệp thấp nhất (thêm thông tin trước, đổi luồng sau). * [ ] Bấm liên tục hoặc bấm đúp không được tạo ra nhiều request/thao tác giống nhau.
- [ ] Thay đổi luồng được đánh dấu là **đề xuất** cần Cowork Team duyệt.
- [ ] Có test regression cho signal/state, chạy headless. ### 3. Trạng thái Lỗi (Error)
* [ ] Thông báo lỗi phải cho biết:
- **Chuyện gì đã xảy ra.**
- **Người dùng cần làm gì tiếp theo.**
* [ ] Có cách để người dùng **thử lại** khi phù hợp.
* [ ] Không hiển thị nguyên exception, stack trace hoặc thông tin kỹ thuật khó hiểu cho người dùng.
### 4. Trạng thái Thành công (Success)
* [ ] Sau khi thao tác thành công, phải có thông báo/xác nhận rõ ràng.
* [ ] Với thao tác khó hoặc không thể hoàn tác, phải có cơ chế **Undo** nếu phù hợp.
---
## B. Kiểm tra an toàn dữ liệu
* [ ] Các ô nhập nội dung dài, ví dụ:
- Instruction
- Composer
- Node property
- AI Edit
```
không được mất nội dung khi:
- Chuyển tab.
- Đóng/mở dialog.
- Đổi project.
```
* [ ] Có cơ chế xác định **dirty-state** khi dữ liệu đã thay đổi nhưng chưa lưu.
* [ ] `closeEvent` phải cảnh báo hoặc chặn việc đóng màn hình khi vẫn còn thay đổi chưa lưu.
* [ ] Các thao tác có thể làm mất dữ liệu phải có bước xác nhận, ví dụ:
- Xóa project.
- Xóa task.
- Ghi đè file.
* [ ] Nội dung xác nhận phải nói rõ **dữ liệu nào sẽ bị mất**.
```
Không dùng thông báo quá chung chung như:
`"Bạn có chắc không?"`
```
* [ ] Nút thực hiện thao tác phá hủy dữ liệu:
- Không được đặt làm **default button**.
- Không được thực hiện khi người dùng chỉ nhấn `Enter`.
---
## C. Kiểm tra phản hồi theo thời gian
Phản hồi của UI phải phù hợp với thời gian xử lý:
* [ ] **100ms – 1s:**
Có thể thay đổi con trỏ hoặc vô hiệu hóa nút để người dùng biết thao tác đã được nhận.
* [ ] **1s – 10s:**
Hiển thị chỉ báo tiến trình rõ ràng.
* [ ] **Trên 10s:**
- Có chỉ báo tiến trình.
- Người dùng có thể **hủy thao tác** khi phù hợp.
- Không khóa toàn bộ UI nếu không cần thiết.
* [ ] Các tác vụ xử lý nặng không được chạy trực tiếp trên GUI thread.
Phải chuyển phần xử lý nặng sang service trong `application/`.
* [ ] Một thao tác không được chạy hai lần khi người dùng bấm liên tục hoặc bấm đúp.
* [ ] Kiểm tra các `connect()` có bị đăng ký nhiều lần hay không, đặc biệt với lỗi **P10**.
---
## D. Kiểm tra khả năng khám phá chức năng
Người dùng phải dễ dàng biết **nút này làm gì và tìm chức năng ở đâu**.
* [ ] Tất cả các nút chỉ có icon (`icon-only`) đều có tooltip.
```
Đặc biệt kiểm tra:
- Nav rail khi thu gọn.
- Toolbar Co4E.
- Top bar.
```
* [ ] Nút đang bị vô hiệu hóa phải cho người dùng biết **tại sao không thể bấm**.
```
Ví dụ sử dụng key:
`app.nav.needs_project`
```
* [ ] Chức năng chính không được chỉ nằm trong menu chuột phải nếu không có cách truy cập khác.
* [ ] Thứ tự các control trên màn hình phải phù hợp với **thứ tự người dùng thực hiện công việc**.
---
## E. Kiểm tra tính nhất quán
* [ ] Một hành động phải sử dụng **cùng một thuật ngữ** trên toàn bộ ứng dụng.
```
Ví dụ:
Nếu dùng `"Lưu"` ở một màn hình thì không nên dùng `"Cập nhật"` ở màn hình khác cho cùng một hành động.
```
* [ ] Vị trí của nút chính và nút phụ phải nhất quán với các dialog khác.
* [ ] Chuỗi text mới phải sử dụng `tr()`.
* [ ] Chuỗi mới phải có bản dịch đầy đủ cho:
```
- `en`
- `ja`
- `vi`
```
* [ ] Không hardcode text mới trực tiếp trong UI code nếu text đó cần hỗ trợ đa ngôn ngữ.
---
## F. Kiểm tra phạm vi thay đổi
* [ ] Bản vá sử dụng **cách can thiệp nhỏ nhất có thể**.
```
Ưu tiên:
**Bổ sung thông tin → cải thiện feedback → điều chỉnh control → thay đổi flow**
Không thay đổi cả luồng khi chỉ cần bổ sung thông tin.
```
* [ ] Nếu cần thay đổi flow của người dùng, thay đổi đó phải được ghi rõ là:
```
**ĐỀ XUẤT**
```
* [ ] Agent không tự quyết định thay đổi product/UX quan trọng.
* [ ] Các thay đổi flow cần được **Cowork Team xem xét và phê duyệt**.
* [ ] Có regression test kiểm tra:
- Signal.
- State.
- Chuyển trạng thái.
- Hành vi của user flow liên quan.
* [ ] Regression test chạy được ở chế độ headless:
```
`QT_QPA_PLATFORM=offscreen`
```
---
## Kết luận
Bản vá UX chỉ nên được đánh giá là đạt khi:
1. Người dùng biết rõ trạng thái hiện tại của hệ thống.
2. Không có nguy cơ mất dữ liệu ngoài ý muốn.
3. UI phản hồi phù hợp với thời gian xử lý.
4. Chức năng dễ tìm và dễ hiểu.
5. Cách gọi tên và cách bố trí control nhất quán.
6. Thay đổi flow lớn đã được đánh dấu để Cowork Team phê duyệt.
7. Có regression test chứng minh flow vẫn hoạt động đúng.
+33
View File
@@ -0,0 +1,33 @@
---
description: Điều phối fix bug UI/UX — chấm tier T0/T1/T2/T3 rồi chạy đúng số agent cần thiết
argument-hint: <phản ánh của người dùng, dán nguyên văn>
---
Bạn đang chạy với vai **`fix-dispatcher`** — agent hub điều phối của bộ agent trong `agent/`.
Nạp theo đúng thứ tự rồi làm theo:
1. @agent/system/guardrail.md
2. @agent/system/security.md
3. @agent/system/response_policy.md
4. @agent/roles/0_fix_dispatcher.md
5. @agent/output/dispatch_plan.md
Phản ánh cần xử lý:
$ARGUMENTS
Trình tự bắt buộc:
- Tách defect (Bước 1) → xét override bảo mật (Bước 2) → chấm tier (Bước 3).
- Trần chấm điểm: **≤ 5 lệnh đọc/grep, 0 subagent**. Hết mà chưa chấm được → T2.
- In `dispatch_plan` (≤ 30 dòng phần người đọc) **trước** khi chạy bất kỳ agent nào.
- Rồi chạy đúng lane ở bảng Bước 4:
- **T0** → tự sửa, sau đó chạy đủ 4 cổng máy ở §4.1 và dán output thật.
- **T1** → gọi `fix-implementer`, rồi tự review bằng @agent/checklist/ui_review.md.
- **T2** → specialist → `fix-implementer` → `regression-reviewer`.
- **T3** → `ui-bug-triage` → specialist → `fix-implementer` → `regression-reviewer`.
- **T3-SEC** → `security-defect-fixer`, dừng chờ Cowork Team trả 4 câu chính sách.
- Các `defect_id` độc lập gọi song song trong **một** message. Các bước trong cùng một
`defect_id` chạy tuần tự.
- Escalate theo Bước 5. Tier chỉ đi lên. Không tự merge (`guardrail.md` G9).
+379 -52
View File
@@ -1,71 +1,398 @@
# i18n — luật chuỗi hiển thị # i18n — Quy tắc xử lý chuỗi hiển thị
Nguồn: docstring `i18n/__init__.py`. **Nguồn:** docstring `i18n/__init__.py`
--- ---
## 1. Ba ngôn ngữ, mặc định tiếng Việt ## 1. Ngôn ngữ được hỗ trợ
Cowork Local hỗ trợ 3 ngôn ngữ:
```python ```python
LANGUAGES = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"} LANGUAGES = {
LANGUAGE_SHORT = {"en": "EN", "ja": "JP", "vi": "VN"} # switcher gọn ở top bar "en": "English",
"ja": "日本語",
"vi": "Tiếng Việt",
}
LANGUAGE_SHORT = {
"en": "EN",
"ja": "JP",
"vi": "VN",
}
DEFAULT_LANGUAGE = "vi" DEFAULT_LANGUAGE = "vi"
``` ```
`tr(key, **kwargs)` trả chuỗi theo ngôn ngữ hiện tại, fallback lần lượt: Ngôn ngữ mặc định là **Tiếng Việt (`vi`)**.
**ngôn ngữ hiện tại → `en` → chính cái key**. Nghĩa là thiếu entry thì UI hiện ra
`workspace.tab_folder` chứ không crash — nếu người dùng chụp màn hình có chuỗi dạng
`a.b_c` thì đó chính là triệu chứng thiếu key.
`.format(**kwargs)` được áp dụng khi có placeholder: `tr("composer.attachments", n=3)`. ### Hàm `tr()`
## 2. Widget nào phải đăng ký callback Sử dụng:
| Loại widget | Cách xử lý | ```python
|---|---| tr(key, **kwargs)
| **Sống lâu** — chrome cửa sổ chính, tab, sidebar, composer | Đăng ký `on_language_changed(cb)`; `cb` áp lại `tr()` cho chính widget đó. Callback chạy **ngay một lần** và mỗi lần đổi ngôn ngữ |
| **Tạm thời** — Settings, Skills, Flow, Permission dialog | Dựng lại từ đầu mỗi lần mở, nên chỉ cần gọi `tr()` lúc construct, **không** đăng ký |
Quy ước đặt tên hàm callback trong repo: `_retranslate()` / `_apply_i18n()` — xem
`ui/workspace_tab.py:484` trở đi làm mẫu chuẩn.
**Bug điển hình:** "Đổi ngôn ngữ nhưng nhãn X không đổi" → widget sống lâu mà quên đăng ký,
hoặc có đăng ký nhưng callback bỏ sót đúng nhãn đó. Không sửa bằng cách gọi `tr()` lại ở
chỗ khác — sửa trong callback.
## 3. File từ điển
`i18n/` chia theo màn hình, không phải một file khổng lồ:
```text
i18n/login_dialog.py i18n/sidebar.py i18n/composer.py
i18n/cowork_tab.py i18n/settings_dialog.py i18n/skills_dialog.py
i18n/libreoffice_view.py i18n/agents_admin_tab.py i18n/monitoring_overview.py
i18n/hint.py
``` ```
Mỗi file export dict `key -> {"en":..., "ja":..., "vi":...}`, được `i18n/__init__.py` để lấy chuỗi hiển thị theo ngôn ngữ hiện tại.
import và gộp lại. Thêm key mới:
1. Chọn đúng file theo màn hình (không nhét đại vào `login_dialog.py` chỉ vì nó lớn nhất). Thứ tự fallback:
2. Điền **đủ 3 ngôn ngữ**. Thiếu `ja` là lỗi hay gặp nhất và chỉ lộ ra khi khách Nhật dùng.
3. Đặt key theo `<màn>.<thành_phần>` — `workspace.tab_folder`, `app.nav.recents`.
## 4. Rủi ro riêng của tiếng Nhật và tiếng Việt ```text
Ngôn ngữ hiện tại → English (en) → chính key
```
| Rủi ro | Triệu chứng | Cách xử lý | Ví dụ, nếu đang dùng tiếng Nhật nhưng key `workspace.tab_folder` chưa có bản dịch tiếng Nhật:
|---|---|---|
| Tiếng Nhật ngắn hơn, tiếng Việt dài hơn tiếng Anh | Nút vừa với `EN`, tràn với `VI`; label bị `...` với `JA` | Không `setFixedWidth` theo chuỗi tiếng Anh. Dùng `sizeHint` + `minimumWidth`, hoặc cho phép wrap |
| Dấu tiếng Việt bị cắt phần trên/dưới | `Ắ`, `ộ` mất dấu ở nhãn cao cố định | Không đặt `setFixedHeight` cho label theo pixel; để layout tự tính |
| Font mặc định thiếu glyph Nhật | Ô vuông tofu `□□□` trên máy chưa cài font | Kiểm tra `_FONT` trong `theme/palettes.py`, khai báo fallback |
| Sắp xếp / so sánh chuỗi | Danh sách project sắp sai với tên có dấu | Dùng `locale`-aware sort, không `sorted()` thô |
| Chiều dài chuỗi tính bằng ký tự ≠ chiều rộng hiển thị | Elide sai với chữ Nhật | Đo bằng `QFontMetrics.horizontalAdvance`, không `len()` |
## 5. Checklist sửa bug i18n ```text
JA → EN → workspace.tab_folder
```
- [ ] Key mới có đủ `en` / `ja` / `vi`? Ứng dụng **không được crash** chỉ vì thiếu bản dịch.
- [ ] Đã thử đổi qua cả 3 ngôn ngữ **trong lúc app đang chạy** (không phải restart)?
- [ ] Widget sống lâu đã đăng ký `on_language_changed`? Nếu UI hiển thị một chuỗi dạng:
- [ ] Không còn chuỗi hardcode nào trong bản vá?
- [ ] Layout còn đúng với chuỗi dài nhất trong 3 ngôn ngữ? ```text
- [ ] Không dùng `len()` để đo bề rộng chữ? workspace.tab_folder
```
thì đây là dấu hiệu cho thấy **đang thiếu translation key**.
### Placeholder
Nếu chuỗi có placeholder, truyền giá trị thông qua `kwargs`:
```python
tr("composer.attachments", n=3)
```
Việc `.format(**kwargs)` được thực hiện sau khi lấy chuỗi dịch.
---
## 2. Widget nào phải cập nhật khi đổi ngôn ngữ?
Có 2 loại widget:
| Loại widget | Cách xử lý |
| ------------------- | ----------------------------------------------------- |
| **Widget sống lâu** | `bind_*` cho chuỗi tĩnh; `on_language_changed(cb)` cho phần còn lại |
| **Widget tạm thời** | Không cần đăng ký callback; gọi `tr()` khi tạo widget |
### 2.0. `bind_*` — cách mặc định cho chuỗi tĩnh
`w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm chạy dòng đó. `bind_*` gộp "gán ngay"
và "gán lại sau mỗi lần đổi ngôn ngữ" vào một lời gọi, dùng `weakref` nên không giữ widget
sống thêm và tự dọn khi widget bị xoá:
```python
from ...i18n import bind_dynamic, bind_items, bind_placeholder, bind_text, bind_tip
self.save_btn = bind_text(QPushButton(), "co4e.save") # thay QPushButton(tr(...))
bind_tip(self.save_btn, "co4e.tt_save") # thay .setToolTip(tr(...))
bind_placeholder(self.chat_input, "co4e.chat_placeholder")
bind_items(self.perm_combo, [f"co4e.perm.{p}" for p in PERMISSION_PRESETS])
form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit) # KHÔNG addRow(tr(...))
```
Ba luật:
1. **Chuỗi tĩnh → `bind_*`.** Đổi tại chỗ, **không thêm dòng** — quan trọng với file đã
sát trần Gate S hoặc đang bị bánh cóc `LEGACY_ALLOWANCE` chốt (`quality_gates.md` §4).
2. **Chữ phụ thuộc trạng thái → `bind_dynamic(w, setter, fn)`**, với `fn` đọc trạng thái:
nút Chạy ⇄ Dừng, tooltip Thu gọn ⇄ Mở rộng, nhãn có số đếm. Các nhánh xử lý trạng thái
**vẫn** gọi setter trực tiếp như cũ để phản hồi ngay khi bấm; `bind_dynamic` chỉ lo lúc
đổi ngôn ngữ. Bind cứng một nhãn động sẽ **xoá** trạng thái khi người dùng đổi ngôn ngữ
giữa lúc đang chạy.
3. **Chữ là DỮ LIỆU thì không bind.** Tên agent, tên project, tên nhà cung cấp trong
`config.PROVIDER_LABELS` — dịch danh tính là sai.
`QFormLayout.addRow(tr(...), w)` và `_add_section(outer, tr(...))` là hai bẫy hay gặp:
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ụ:
* Chrome của cửa sổ chính.
* Tab.
* Sidebar.
* Composer.
Các widget này vẫn tồn tại khi người dùng đổi ngôn ngữ.
Vì vậy phải:
1. Đăng ký `on_language_changed(cb)`.
2. Trong callback, gọi lại `tr()` cho các text của chính widget.
3. Callback phải chạy:
* Một lần ngay khi đăng ký.
* Mỗi lần người dùng đổi ngôn ngữ.
Tên callback được sử dụng trong repo:
```text
_retranslate()
_apply_i18n()
```
Có thể tham khảo implementation chuẩn từ:
```text
ui/workspace_tab.py:484
```
### 2.2. Widget tạm thời
Ví dụ:
* Settings dialog.
* Skills dialog.
* Flow dialog.
* Permission dialog.
Các dialog này được tạo lại từ đầu mỗi lần mở.
Vì vậy chỉ cần gọi `tr()` khi construct widget.
**Không cần đăng ký `on_language_changed()`**.
### Bug thường gặp
Triệu chứng:
> Đổi ngôn ngữ nhưng một label/nút vẫn giữ ngôn ngữ cũ.
Nguyên nhân thường là:
* Widget sống lâu nhưng chưa đăng ký `on_language_changed()`.
* Callback có đăng ký nhưng quên cập nhật label đó.
**Cách sửa đúng:**
`bind_*` tại chính dòng đang gán (mục 2.0), hoặc — nếu chữ phụ thuộc trạng thái/dữ liệu —
sửa trong `_retranslate()` / `_apply_i18n()` của chính widget.
**Không** giải quyết bằng cách gọi `tr()` ở một nơi khác chỉ để ép label thay đổi.
### Cách TÌM ra hết các chỗ bị lỗi
Đừng grep chuỗi tiếng Việt trong source: lượt audit tháng 9/2026 grep ra 962 dòng mà
**không dòng nào** là lỗi thật (toàn docstring), trong khi 84 lỗi thật lại không xuất hiện
— vì chúng đi qua `tr()` đúng cách, chỉ thiếu người áp lại.
Phép đo đúng nằm ở `tests/ui/test_i18n_khong_con_chu_cu.py`: dựng `MainWindow` thật, thay
`tr()` bằng chuỗi **mốc**, gọi `set_language()`, rồi tìm chỗ **không** mang mốc. Hai chi
tiết mà bản kiểm ngây thơ sẽ sai:
* `from ...i18n import tr` copy tham chiếu vào namespace từng module → phải thay `tr` ở
**mọi** module đã import, không chỉ `i18n.tr`;
* lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống → không
`sendPostedEvents(DeferredDelete)` thì báo oan hàng chục widget bóng ma (lượt audit đầu
báo 84 lỗi, trong đó 65 là bóng ma và widget bị `id()` cấp lại làm cắt vòng quét).
Chạy: `QT_QPA_PLATFORM=offscreen pytest tests/ui/test_i18n_khong_con_chu_cu.py -q`
---
## 3. Tổ chức file translation
Thư mục `i18n/` được chia theo **màn hình/chức năng**, không gom tất cả translation vào một file lớn.
Ví dụ:
```text
i18n/
├── login_dialog.py
├── sidebar.py
├── composer.py
├── cowork_tab.py
├── settings_dialog.py
├── skills_dialog.py
├── libreoffice_view.py
├── agents_admin_tab.py
├── monitoring_overview.py
└── hint.py
```
Mỗi file export một dictionary có dạng:
```text
key → {
"en": "...",
"ja": "...",
"vi": "..."
}
```
`i18n/__init__.py` sẽ import và gộp các dictionary này.
### Khi thêm key mới
Thực hiện theo 3 bước:
#### Bước 1 — Chọn đúng file
Đưa key vào file tương ứng với màn hình/chức năng.
Ví dụ:
```text
workspace.* → file liên quan đến workspace
composer.* → composer.py
settings.* → settings_dialog.py
```
**Không** đưa key vào `login_dialog.py` chỉ vì file đó đang có nhiều key nhất.
#### Bước 2 — Điền đủ 3 ngôn ngữ
Mỗi key mới phải có:
```text
en
ja
vi
```
Thiếu `ja` là lỗi đặc biệt cần chú ý vì có thể chỉ được phát hiện khi khách hàng Nhật sử dụng.
#### Bước 3 — Đặt tên key nhất quán
Format khuyến nghị:
```text
<màn hình>.<thành phần>
```
Ví dụ:
```text
workspace.tab_folder
app.nav.recents
```
Tên key phải mô tả rõ nó được dùng ở đâu và cho thành phần nào.
---
## 4. Các rủi ro thường gặp với tiếng Nhật và tiếng Việt
| Vấn đề | Triệu chứng | Cách xử lý |
| ---------------------------------- | -------------------------------------------- | ------------------------------------------------------------------------------------------------ |
| Độ dài chuỗi khác nhau | EN vừa nút nhưng VI bị tràn hoặc JA bị `...` | Không đặt width cố định dựa trên tiếng Anh. Dùng `sizeHint()`, `minimumWidth` hoặc cho phép wrap |
| Dấu tiếng Việt bị cắt | Các chữ như `Ắ`, `ộ` bị mất dấu | Không dùng `setFixedHeight()` cho label. Để layout tự tính chiều cao |
| Thiếu font/glyph tiếng Nhật | Xuất hiện `□□□` | Kiểm tra `_FONT` trong `theme/palettes.py` và khai báo font fallback |
| Sắp xếp chuỗi | Project có dấu được sắp xếp không đúng | Dùng locale-aware sorting, không dùng `sorted()` một cách máy móc |
| Số ký tự không phản ánh chiều rộng | Text bị elide sai, đặc biệt với tiếng Nhật | Dùng `QFontMetrics.horizontalAdvance()`, không dùng `len()` để đo chiều rộng |
### Đặc biệt lưu ý về độ dài text
Không được giả định:
```text
số ký tự = chiều rộng hiển thị
```
Ví dụ hai chuỗi có cùng số ký tự nhưng có thể có chiều rộng hiển thị khác nhau.
Khi cần đo text trên UI, dùng:
```python
QFontMetrics.horizontalAdvance(...)
```
---
## 5. Checklist khi sửa lỗi i18n
Trước khi hoàn thành bản vá i18n, phải kiểm tra:
* [ ] Key mới có đủ **`en` / `ja` / `vi`**?
* [ ] Đã chuyển qua cả 3 ngôn ngữ **ngay trong lúc app đang chạy** chưa?
```
Không chỉ restart app rồi kiểm tra.
```
* [ ] `ja` có **khác** `en` không? Bằng nhau nghĩa là chưa dịch — trừ tên thương hiệu /
ký hiệu, và khi đó phải khai vào `KHOA_KHONG_CAN_DICH` kèm lý do.
* [ ] Chuỗi tĩnh đã dùng `bind_text` / `bind_tip` / `bind_placeholder` / `bind_items`
thay cho `setX(tr(...))` một lần?
* [ ] Chữ phụ thuộc trạng thái đã dùng `bind_dynamic` (không bind cứng, kẻo mất trạng thái)?
* [ ] Nếu widget sống lâu và còn phần không bind được, đã đăng ký:
```
`on_language_changed(...)`
```
* [ ] Callback `_retranslate()` hoặc `_apply_i18n()` đã cập nhật **tất cả text liên quan**?
* [ ] Đã chạy `pytest tests/ui/test_i18n_khong_con_chu_cu.py -q` và nó **xanh**?
* [ ] 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?
* [ ] Nếu có thay đổi UI, đã kiểm tra cả Dark Mode và Light Mode?
---
## 6. Nguyên tắc quan trọng
Khi sửa lỗi i18n, **không sửa triệu chứng ở nơi khác**.
Ví dụ:
```text
Đổi ngôn ngữ
↓
Label X không thay đổi
↓
Kiểm tra widget X
↓
Widget sống lâu?
↓
Có on_language_changed()?
↓
_retranslate() có cập nhật Label X?
```
Nếu thiếu callback hoặc callback bỏ sót label, hãy sửa **đúng callback của widget đó**.
Không thêm các lệnh `tr()` rải rác ở nơi khác chỉ để làm cho UI thay đổi.
Mục tiêu là đảm bảo cơ chế i18n hoạt động đúng và nhất quán cho toàn bộ ứng dụng.
+443 -58
View File
@@ -1,95 +1,480 @@
# Screen Map — dịch lời người dùng thành file:line # Screen Map — Tra mô tả của người dùng về đúng file:line
Người dùng báo lỗi bằng lời ("cái bảng bên phải màn thống kê"). File này để agent Người dùng thường mô tả lỗi bằng ngôn ngữ tự nhiên, ví dụ:
Triage quy nó về đúng widget.
> "Cái bảng bên phải của màn thống kê bị lệch."
Agent phải dùng file này để chuyển mô tả đó thành:
```text
Màn hình → Tab/View → Widget → File → Line → Control
```
Mục tiêu là tìm được **đúng widget và đúng vị trí code**, thay vì đoán file dựa trên tên.
--- ---
## 1. Nav rail — bốn màn chính ## 1. Bốn màn hình chính trong Nav Rail
Định nghĩa tại `presentation/shell/main_window.py:151` (`_nav_defs`), thứ tự = page index: Các màn hình chính được định nghĩa tại:
| Row | i18n key | Icon | Dựng | Widget | ```text
|---|---|---|---|---| presentation/shell/main_window.py:151
| 0 | `app.tab.dashboard` | `dashboard` | lười | `presentation/dashboard/dashboard_tab.py::DashboardTab` | ```
| 1 | `app.tab.schedule` | `schedule` | lười | `presentation/scheduling/schedule_task_tab.py::ScheduleTaskTab` |
| 2 | `app.tab.workspace` | `workspaces` | **ngay** (màn HOME) | `ui/workspace_tab.py::WorkspaceTab` |
| 3 | `app.tab.monitoring` | `monitoring` | lười | `ui/monitoring_tab.py::MonitoringTab` |
App mở lên là ở **Workspace ▸ Project**. Danh sách nằm trong `_nav_defs`.
## 2. Sub-tab của Workspace **Thứ tự trong bảng chính là page index.**
`ui/workspace_tab.py:214-245`: | Row | i18n key | Icon | Cách tạo | Widget |
| --: | -------------------- | ------------ | --------------- | --------------------------------------------------------------- |
| 0 | `app.tab.dashboard` | `dashboard` | Lazy | `presentation/dashboard/dashboard_tab.py::DashboardTab` |
| 1 | `app.tab.schedule` | `schedule` | Lazy | `presentation/scheduling/schedule_task_tab.py::ScheduleTaskTab` |
| 2 | `app.tab.workspace` | `workspaces` | Ngay khi mở app | `ui/workspace_tab.py::WorkspaceTab` |
| 3 | `app.tab.monitoring` | `monitoring` | Lazy | `ui/monitoring_tab.py::MonitoringTab` |
| Tab | i18n key | Widget | ### Màn hình mặc định
|---|---|---|
| Project | `workspace.tab_project` | `_build_project_tab()` trong chính file đó |
| Cowork | `workspace.tab_cowork` | `ui/cowork_tab.py` |
| Co4E | `workspace.tab_co4e` | `ui/co4e_tab.py` → `presentation/co4e/` |
| Folder | `workspace.tab_folder` | `presentation/folder/folder_tab.py` |
| GraphRAG | `workspace.tab_graphrag` | `presentation/graph/structure_graph_view.py` |
Monitoring **giữ tab strip riêng** với 8 sub-view (tổng quan, trạng thái agent, công cụ, Khi mở app, người dùng bắt đầu tại:
nhật ký hành động, lịch sử gọi MCP, sự kiện bảo mật, agents admin, icon). Workspace là màn
duy nhất giấu tab strip đi.
## 3. Thành phần luôn nổi trên mọi màn ```text
Workspace → Project
```
| Thành phần | File | Triệu chứng người dùng hay mô tả | ### Lưu ý về Lazy
|---|---|---|
| Nav rail trái, nút thu gọn | `presentation/shell/nav_rail.py` | "menu bị co lại", "không thấy tên project" |
| Top bar (theme, ngôn ngữ) | `presentation/shell/top_bar.py` | "đổi giao diện không ăn" |
| Toast góc trên trái | `presentation/shell/toast.py` | "thông báo xong việc che mất nút" |
| Help agent nổi góc dưới phải | `ui/help_agent_widget.py` | "con robot che nút gửi" |
| Status bar dưới cùng | `main_window.statusBar()` | "dòng chữ dưới đáy không đổi" |
## 4. Dialog `Dashboard`, `Schedule` và `Monitoring` được tạo **lazy** — chỉ được dựng khi người dùng mở màn hình.
`ui/`: `login_dialog.py`, `permission_dialog.py`, `settings_dialog.py`, `skills_dialog.py`, Vì vậy, khi điều tra lỗi liên quan đến các màn hình này, phải kiểm tra cả **thời điểm widget được tạo** và **vòng đời của widget**.
`task_editor_dialog.py`, `file_edit_dialog.py`, `flow_dialog.py`, `mcp_servers_dialog.py`,
`co4e_agent_dialog.py`, `ext_connector_dialog.py`.
## 5. 🔎 Hai file tra cứu bắt buộc dùng ---
### `docs/screens/manifest.json` ## 2. Các tab bên trong Workspace
Mỗi màn đã chụp ảnh có một entry: `slug`, `title`, `theme`, `note` (**đúng `file.py:line` Các tab được định nghĩa trong:
nơi màn đó được dựng**), `file` (ảnh), `nav`.
```text
ui/workspace_tab.py:214-245
```
| Tab | i18n key | Widget/File |
| -------- | ------------------------ | -------------------------------------------------- |
| Project | `workspace.tab_project` | `_build_project_tab()` trong `ui/workspace_tab.py` |
| Cowork | `workspace.tab_cowork` | `ui/cowork_tab.py` |
| Co4E | `workspace.tab_co4e` | `ui/co4e_tab.py` → `presentation/co4e/` |
| Folder | `workspace.tab_folder` | `presentation/folder/folder_tab.py` |
| GraphRAG | `workspace.tab_graphrag` | `presentation/graph/structure_graph_view.py` |
### Monitoring có cấu trúc khác
Monitoring có **tab strip riêng**, gồm 8 sub-view:
1. Tổng quan.
2. Trạng thái Agent.
3. Công cụ.
4. Nhật ký hành động.
5. Lịch sử gọi MCP.
6. Sự kiện bảo mật.
7. Agents Admin.
8. Icon.
**Workspace là màn hình duy nhất không hiển thị tab strip theo cách này.**
Nếu người dùng nói:
> "Tab trạng thái agent trong màn Monitoring"
thì không được nhầm nó với một tab của Workspace.
---
## 3. Các thành phần luôn xuất hiện trên mọi màn hình
Một số thành phần nằm ngoài nội dung của từng màn hình.
| Thành phần | File | Cách người dùng thường mô tả |
| ------------------------------- | -------------------------------- | -------------------------------------------------- |
| Nav rail bên trái / nút thu gọn | `presentation/shell/nav_rail.py` | "Menu bị co lại", "Không thấy tên project" |
| Top bar / theme / ngôn ngữ | `presentation/shell/top_bar.py` | "Đổi giao diện không ăn", "Đổi ngôn ngữ không đổi" |
| Toast góc trên trái | `presentation/shell/toast.py` | "Thông báo xong việc che mất nút" |
| Help Agent góc dưới phải | `ui/help_agent_widget.py` | "Con robot che nút gửi" |
| Status bar phía dưới | `main_window.statusBar()` | "Dòng chữ dưới đáy không đổi" |
### Quy tắc
Nếu người dùng mô tả một thành phần thuộc nhóm trên, **không cần tìm sub-tab trước**.
Hãy kiểm tra trực tiếp file tương ứng.
---
## 4. Các Dialog
Các dialog chính nằm trong `ui/`:
```text
ui/
├── login_dialog.py
├── permission_dialog.py
├── settings_dialog.py
├── skills_dialog.py
├── task_editor_dialog.py
├── file_edit_dialog.py
├── flow_dialog.py
├── mcp_servers_dialog.py
├── co4e_agent_dialog.py
└── ext_connector_dialog.py
```
Ví dụ:
> "Khi mở Permission thì nút Allow bị..."
→ kiểm tra trước:
```text
ui/permission_dialog.py
```
Không tự động tìm trong `presentation/` chỉ vì lỗi xảy ra trên UI.
---
# 5. Hai file tra cứu bắt buộc
Khi cần chuyển mô tả của người dùng thành `file:line`, phải ưu tiên sử dụng:
```text
docs/screens/manifest.json
docs/screens/controls.json
```
---
## 5.1. `docs/screens/manifest.json`
File này chứa thông tin về các màn hình đã được chụp screenshot.
Mỗi màn hình có các thông tin chính:
```text
slug
title
theme
note
file
nav
```
Trong đó:
* `slug` — tên định danh của màn hình.
* `title` — tên hiển thị.
* `theme` — Dark hoặc Light.
* `note` — **vị trí code dựng màn hình (`file.py:line`)**.
* `file` — đường dẫn đến screenshot.
* `nav` — màn hình thuộc nav nào.
### Ví dụ
Người dùng nói:
> "Màn Kanban lịch trình bị lỗi."
Có thể tìm màn hình liên quan bằng:
```bash ```bash
# Người dùng nói "màn Kanban lịch trình"
python -c "import json;print([e for e in json.load(open('docs/screens/manifest.json')) if 'schedule' in e['slug']])" python -c "import json;print([e for e in json.load(open('docs/screens/manifest.json')) if 'schedule' in e['slug']])"
``` ```
Ảnh có **cả bản dark và light** (`*-dark.png` / `*-light.png`) — dùng để đối chiếu trước/sau Sau đó lấy `note` để biết:
và để kiểm tra bug chỉ xảy ra ở một theme.
### `docs/screens/controls.json` ```text
file.py:line
```
Danh mục **mọi control** đã trích tự động từ source: `file`, `var`, `type` (`QLineEdit`...), ### Screenshot Dark và Light
`kind` (mô tả tiếng Việt: "ô nhập", "nút"...), `label`, `line`, `signals`, `object_name`.
Mỗi màn hình thường có hai ảnh:
```text
<slug>-dark.png
<slug>-light.png
```
Dùng hai ảnh này để:
* So sánh trước/sau.
* Kiểm tra lỗi chỉ xảy ra ở một theme.
* Kiểm tra sự khác biệt giữa Dark Mode và Light Mode.
---
## 5.2. `docs/screens/controls.json`
Đây là danh sách các control được trích tự động từ source code.
Mỗi control có thông tin như:
```text
file
var
type
kind
label
line
signals
object_name
```
Trong đó:
* `file` — file chứa control.
* `var` — tên biến.
* `type` — loại widget, ví dụ `QLineEdit`.
* `kind` — mô tả dễ hiểu, ví dụ `"ô nhập"`, `"nút"`.
* `label` — text/label liên quan.
* `line` — dòng code.
* `signals` — signal liên quan.
* `object_name` — `objectName` của widget.
### Ví dụ
Người dùng nói:
> "Ô nhập email trong màn tài khoản bị lỗi."
Có thể tìm control bằng:
```bash ```bash
# Người dùng nói "ô nhập email trong màn tài khoản"
python - <<'PY' python - <<'PY'
import json import json
for f in json.load(open('docs/screens/controls.json')): for f in json.load(open('docs/screens/controls.json')):
for c in f['controls']: for c in f['controls']:
if 'email' in (c['var'] + c['label']).lower(): text = (c['var'] + c['label']).lower()
print(f["file"], c["line"], c["var"], c["type"], c["object_name"]) if 'email' in text:
print(
f["file"],
c["line"],
c["var"],
c["type"],
c["object_name"]
)
PY PY
``` ```
Cột `object_name` đặc biệt quan trọng khi sửa bug màu/style: rỗng nghĩa là widget **chưa** Từ kết quả có thể xác định:
được style qua `_TEMPLATE`, nên nó đang ăn style mặc định của class — thường chính là
nguyên nhân của "chỗ này nhìn khác chỗ kia".
## 6. Quy trình tra 4 bước cho Triage ```text
file
line
variable
widget type
objectName
```
1. Xác định **nav row** (Dashboard / Schedule / Workspace / Monitoring) từ mô tả hoặc ảnh. ---
2. Xác định **sub-tab / dialog**.
3. Tra `manifest.json` → lấy `note` = `file.py:line`.
4. Tra `controls.json` → lấy đúng `var` + `line` + `object_name` của control bị lỗi.
Không qua đủ 4 bước thì `confidence` tối đa là `low`. ## 6. `object_name` đặc biệt quan trọng khi điều tra UI
Khi sửa lỗi màu hoặc style, phải chú ý đến:
```text
object_name
```
Nếu `object_name` đang rỗng, có nghĩa widget đó **chưa được gắn `objectName` để áp style theo cơ chế template/QSS**.
Khi đó widget có thể đang sử dụng style mặc định của class.
Đây thường là nguyên nhân khiến người dùng thấy:
> "Chỗ này nhìn khác chỗ kia."
Ví dụ:
```text
Widget A → objectName = "project_title"
↓
QSS áp style riêng
Widget B → objectName = ""
↓
dùng style mặc định
```
Vì vậy, khi gặp lỗi visual liên quan đến màu/style, hãy kiểm tra `object_name` trước khi tự thêm màu hoặc `setStyleSheet()`.
---
# 7. Quy trình 4 bước dành cho Triage
Khi người dùng báo lỗi bằng ngôn ngữ tự nhiên, thực hiện theo thứ tự sau:
### Bước 1 — Xác định màn hình chính
Xác định lỗi thuộc:
```text
Dashboard
Schedule
Workspace
Monitoring
```
Dựa trên mô tả của người dùng hoặc screenshot.
---
### Bước 2 — Xác định tab/view/dialog
Tiếp tục xác định:
```text
Sub-tab
→ View
→ Dialog
```
Ví dụ:
```text
Workspace
→ Co4E
→ Agent Dialog
```
hoặc:
```text
Monitoring
→ Security Events
```
---
### Bước 3 — Tra `manifest.json`
Mở:
```text
docs/screens/manifest.json
```
Tìm màn hình tương ứng và lấy:
```text
note → file.py:line
```
Đây là điểm bắt đầu để tìm code dựng màn hình.
---
### Bước 4 — Tra `controls.json`
Nếu lỗi liên quan đến một control cụ thể, tiếp tục tìm trong:
```text
docs/screens/controls.json
```
Lấy:
```text
var
line
type
object_name
```
Sau đó xác định chính xác widget bị lỗi.
---
# 8. Quy tắc về Confidence
Triage phải phản ánh đúng mức độ chắc chắn của kết quả.
Nếu chưa hoàn thành đủ 4 bước:
```text
1. Nav
2. Tab/View/Dialog
3. manifest.json
4. controls.json
```
thì:
```yaml
confidence: low
```
Không được tự nâng lên `medium` hoặc `high` chỉ vì file nhìn có vẻ đúng.
### Khi nào có thể tăng Confidence?
Chỉ tăng khi có bằng chứng cụ thể, ví dụ:
```text
User description
↓
Dashboard
↓
Statistics view
↓
manifest.json
↓
presentation/dashboard/dashboard_tab.py:123
↓
controls.json
↓
QTableView
↓
line 245
```
Khi đó mới có đủ cơ sở để ghi nhận `file:line` và đánh giá confidence cao hơn.
---
# 9. Nguyên tắc quan trọng
**Không đoán file từ tên.**
Không nên suy luận kiểu:
> "Lỗi ở Workspace nên chắc chắn nằm trong `workspace_tab.py`."
Thay vào đó:
```text
Mô tả của user
↓
Xác định màn hình
↓
Xác định tab/view/dialog
↓
Tra manifest.json
↓
Xác định file:line
↓
Tra controls.json
↓
Xác định widget/control
↓
Đánh giá confidence
```
Mục tiêu cuối cùng của Screen Map là biến một mô tả mơ hồ của người dùng thành một đầu vào có thể sử dụng được cho `defect_record`, đặc biệt là:
```text
screen
widget
file
line
object_name
confidence
```
File diff suppressed because it is too large Load Diff
+635 -71
View File
@@ -1,101 +1,665 @@
# Theme & Design Tokens — luật màu sắc của Cowork Local # Theme & Design Tokens — Luật màu sắc của Cowork Local
Nguồn: docstring đầu `theme/__init__.py`, `theme/palettes.py`, `theme/qss.py`, > Knowledge module dành cho các agent xử lý **UI Visual / Theme / QSS** của Cowork Local.
`theme/qss_controls.py`.
## Nguồn chính
* `theme/__init__.py` — docstring và API theme
* `theme/palettes.py` — định nghĩa Palette/token
* `theme/qss.py` — `_TEMPLATE` và stylesheet
* `theme/qss_controls.py` — style cho các Qt controls
--- ---
## 1. Luật gốc # 1. Luật quan trọng nhất
> **Không file nào ngoài `theme/` được đặt tên một màu.** > **Ngoài thư mục `theme/`, không file nào được tự định nghĩa màu.**
Cơ chế duy nhất: Luồng màu chuẩn của Cowork Local:
```text ```text
Palette (token ngữ nghĩa) → _TEMPLATE (một QSS duy nhất) → stylesheet(theme) Palette
↓
token ngữ nghĩa
↓
_TEMPL​ATE
↓
stylesheet(theme)
↓
QApplication.setStyleSheet(...)
``` ```
Hai cách hợp lệ để một widget có màu: Nói đơn giản:
1. **Khai báo** — gán `objectName` cho widget, style nó trong `_TEMPLATE` > **Widget không tự chọn màu. Theme quyết định màu.**
(`theme/qss.py`). Đây là cách mặc định.
2. **Vẽ tay** — widget vẽ bằng `QPainter` (chart, canvas, syntax highlighter) thì gọi
`current_palette()` rồi đọc token.
Cách **không** hợp lệ, bị reject review: ---
# 2. Hai cách hợp lệ để widget có màu
## Cách 1 — Style bằng QSS
Đây là cách mặc định.
Widget đặt `objectName`, sau đó style được định nghĩa trong:
```text
theme/qss.py
```
Ví dụ:
```python ```python
self.label.setStyleSheet("color: #dc2626;") # ❌ hex ngoài theme/ widget.setObjectName("my_widget")
pen.setColor(QColor("red")) # ❌ tên màu literal
self.card.setStyleSheet("background: rgba(0,0,0,.1)") # ❌
``` ```
## 2. API cần nhớ và style tương ứng nằm trong `_TEMPLATE`.
| Hàm | Dùng khi | ---
|---|---|
| `theme.stylesheet(theme)` | Sinh QSS toàn app, truyền vào `QApplication.setStyleSheet` |
| `theme.set_active_theme(theme)` | **Phải** gọi ngay cạnh mỗi `setStyleSheet(stylesheet(...))` |
| `theme.current_theme()` | `'dark'` / `'light'` đang hiển thị |
| `theme.current_palette()` | Token của theme đang hiển thị — dùng trong `paintEvent` |
| `theme.palette(theme)` | Token của một theme cụ thể |
| `theme.resolve_theme('system')` | Suy ra dark/light từ color scheme của OS |
| `theme.role_colors(theme)` | Màu theo vai trò hội thoại: user/assistant/tool/result/error |
`current_palette()` tồn tại để code vẽ **không** phải đọc lại `config.json` mỗi lần ## Cách 2 — Widget tự vẽ bằng `QPainter`
repaint — đó từng là bug hiệu năng thật. Không thay bằng đọc config.
## 3. Nhóm token Dùng cho các thành phần như:
Palette là `@dataclass(frozen=True)`. Các nhóm chính: * chart;
* canvas;
* syntax highlighter;
* custom painting.
| Nhóm | Token | Ý nghĩa | Code phải lấy màu từ:
|---|---|---|
| Bề mặt (thang 4 bậc) | `bg` | nền cửa sổ / canvas |
| | `surface` | panel, card, group box (**không** phải nav rail) |
| | `surface_raised` | input, list, tree — thứ người dùng gõ/chọn |
| | `overlay` | menu, tooltip, popup |
| | `sunken` | log, code, terminal — thứ để đọc vào |
| | `hover` / `active` | trạng thái hover / đang bấm |
| Chữ | `text`, `text_muted`, ... | |
| Nhấn | `accent`, `accent_solid` | **Hai token khác nhau có chủ đích**: màu đọc được *dạng chữ* trên nền tối thì quá nhạt để làm *nền* cho chữ trắng |
| Trạng thái | `danger`, ... | |
| Vai trò hội thoại | `role_user`, `role_assistant`, `role_tool`, `role_result`, `role_error` | |
| Code | `code_string`, ... | syntax highlighting |
Token là **ngữ nghĩa**, không phải literal: `danger` / `text_muted` — không bao giờ ```python
`blue` / `grey2`. Thêm một theme = thêm một `Palette`, không phải sửa stylesheet. current_palette()
```
## 4. Ràng buộc thiết kế (đừng "sửa" nhầm thành bug) Ví dụ:
- **Không gradient, không glow.** Bảng màu lấy từ VS Code "Dark Modern" / "Light Modern". ```python
Bề mặt phẳng, góc gần vuông, một màu accent chỉ dành cho thứ người dùng thao tác. palette = current_palette()
- **Chiều sâu đến từ thang bề mặt và viền mảnh**, không từ màu. ```
- **Silhouette VS Code:** nav rail **tối hơn** vùng nội dung, không sáng hơn.
Người dùng báo "menu trái tối quá" — đó là thiết kế, không phải bug. Xem `examples/bad_fix.md`.
- **Contrast giữ ở WCAG AA (4.5:1)** cho body text và cho chữ trên nút đặc.
- Bốn giá trị của VS Code không đạt AA đã được nhích lên vừa đủ (số dòng dark 3.59:1,
chữ mờ trên sidebar sáng 4.28:1, xanh lá sáng 4.33:1, hổ phách sáng 3.12:1). Mỗi chỗ có
comment ghi giá trị gốc — **không** trả chúng về giá trị VS Code.
## 5. Mũi tên combo box (`_chevron_asset`) Sau đó dùng token từ palette.
QSS `image:` chỉ nhận đường dẫn file/resource, không nhận `QPixmap`. Và một khi ---
`::drop-down` / `::up-button` / `::down-button` bị style, Qt **ngừng vẽ mũi tên mặc định**.
Vì vậy `theme/palettes.py::_chevron_asset` render sẵn PNG chevron ra thư mục tạm và cache
theo hash `(direction, color)`.
Hệ quả khi debug: # 3. Những cách KHÔNG được phép
- "Combo box mất mũi tên" → gần như luôn do một stylesheet cục bộ đè lên `::drop-down`. Không được tự đặt màu trong UI code.
- File cache nằm ở `%TEMP%/cowork_local_theme/chevron_*.png`. Xoá nó để buộc render lại
khi test màu mới.
## 6. Checklist sửa bug liên quan màu sắc ### ❌ Hardcode HEX
- [ ] Đã kiểm tra bug xuất hiện ở **cả** dark và light chưa? (`docs/screens/*-dark.png` / `*-light.png`) ```python
- [ ] Bản sửa dùng token, không dùng hex? self.label.setStyleSheet("color: #dc2626;")
- [ ] Nếu thêm token mới: đã thêm cho **cả** `DARK` và `LIGHT`? ```
- [ ] Nếu là chữ trên nền đặc: đã dùng `accent_solid` thay vì `accent`?
- [ ] Contrast còn ≥ 4.5:1? ### ❌ Hardcode tên màu
- [ ] Widget dựng sau khi đổi theme có nhận đúng stylesheet? (xem `qt_pitfalls.md` P07)
```python
pen.setColor(QColor("red"))
```
### ❌ Hardcode RGBA
```python
self.card.setStyleSheet(
"background: rgba(0,0,0,.1)"
)
```
Các trường hợp này phải bị reject khi review.
### Rule ngắn gọn
```text
Không có màu literal ngoài theme/
```
Không chỉ tránh `#hex`, mà cả:
* tên màu;
* RGB;
* RGBA;
* stylesheet cục bộ chứa màu.
---
# 4. API Theme cần nhớ
| API | Dùng để |
| ------------------------------- | --------------------------------------------------- |
| `theme.stylesheet(theme)` | Tạo QSS cho toàn app |
| `theme.set_active_theme(theme)` | Ghi nhận theme hiện đang active |
| `theme.current_theme()` | Lấy theme hiện tại: `dark` / `light` |
| `theme.current_palette()` | Lấy Palette của theme hiện tại |
| `theme.palette(theme)` | Lấy Palette của một theme cụ thể |
| `theme.resolve_theme("system")` | Xác định dark/light theo OS |
| `theme.role_colors(theme)` | Lấy màu theo role: user/assistant/tool/result/error |
---
## Khi đổi theme
Hai lệnh này phải đi cùng nhau:
```python
theme.set_active_theme(theme)
app.setStyleSheet(theme.stylesheet(theme))
```
Không được chỉ gọi `setStyleSheet()` mà quên cập nhật active theme.
---
# 5. `current_palette()` dùng để làm gì?
Code vẽ bằng `QPainter` phải dùng:
```python
current_palette()
```
Không được mỗi lần `paintEvent()` lại đọc:
```text
config.json
```
Lý do:
```text
paintEvent()
↓
repaint
↓
đọc config
↓
lặp lại rất nhiều lần
```
Điều này từng gây vấn đề hiệu năng thực tế.
Vì vậy:
> `current_palette()` tồn tại để custom painting lấy màu nhanh từ theme hiện tại.
---
# 6. Palette và Design Token
`Palette` là:
```python
@dataclass(frozen=True)
```
Token phải mang **ý nghĩa**, không phải tên màu.
### ❌ Không đặt token kiểu:
```text
blue
grey2
dark_blue
light_grey
```
### ✅ Đặt theo vai trò:
```text
accent
danger
text
text_muted
surface
surface_raised
```
Lợi ích:
> Thêm theme mới = thêm một `Palette`, không phải viết lại stylesheet.
---
# 7. Các nhóm token chính
## 7.1. Surface — các mức bề mặt
| Token | Dùng cho |
| ---------------- | -------------------------------------------- |
| `bg` | Nền chính của cửa sổ/canvas |
| `surface` | Panel, card, group box |
| `surface_raised` | Input, list, tree — nơi người dùng nhập/chọn |
| `overlay` | Menu, tooltip, popup |
| `sunken` | Log, code, terminal — vùng chủ yếu để đọc |
| `hover` | Trạng thái hover |
| `active` | Trạng thái đang active/pressed |
### Lưu ý
`surface` **không có nghĩa là nav rail**.
Nav rail có chủ đích riêng về độ sáng/tối.
---
## 7.2. Text
Các token chính:
```text
text
text_muted
...
```
Dùng token theo vai trò thay vì tự chọn màu.
---
## 7.3. Accent
Có hai token:
```text
accent
accent_solid
```
**Hai token này khác nhau có chủ đích.**
### `accent`
Dùng cho accent thông thường, ví dụ:
* trạng thái;
* thành phần UI;
* điểm nhấn.
### `accent_solid`
Dùng khi accent trở thành **nền đặc và bên trên có chữ**.
Lý do:
> Một màu accent có thể đủ sáng để đọc khi dùng như chữ trên nền tối, nhưng lại quá sáng khi dùng làm nền cho chữ trắng.
Vì vậy:
```text
Chữ trên nền accent đặc
↓
accent_solid
```
Không tự lấy `accent` chỉ vì nó có vẻ "cùng màu".
---
## 7.4. State
Ví dụ:
```text
danger
...
```
Các state token cũng phải mang ý nghĩa, không đặt theo tên màu.
---
## 7.5. Conversation roles
Có các token:
```text
role_user
role_assistant
role_tool
role_result
role_error
```
Dùng để phân biệt các role trong giao diện hội thoại.
---
## 7.6. Code / Syntax
Ví dụ:
```text
code_string
...
```
Dùng cho syntax highlighting.
---
# 8. Các nguyên tắc thiết kế — đừng nhầm thành bug
Một số đặc điểm nhìn "khác mắt" nhưng **có chủ đích**.
Không được tự ý sửa chỉ vì người dùng nói "trông hơi tối" hoặc "không giống app hiện đại".
---
## 8.1. Không gradient, không glow
Thiết kế lấy cảm hứng từ:
```text
VS Code Dark Modern
VS Code Light Modern
```
Phong cách chính:
* surface phẳng;
* góc gần vuông;
* không gradient;
* không glow;
* một accent chính;
* accent dành cho thứ người dùng tương tác.
---
## 8.2. Độ sâu đến từ surface và border
Không tạo chiều sâu bằng cách:
```text
đổi màu quá mạnh
```
Thay vào đó dùng:
```text
surface hierarchy
+
border mảnh
```
---
# 9. Nav rail tối hơn là thiết kế có chủ đích
Silhouette của Cowork Local lấy theo VS Code:
```text
NAV RAIL
↓
tối hơn
↓
CONTENT AREA
```
Không phải:
```text
nav rail sáng hơn content
```
Vì vậy nếu user báo:
> "Menu bên trái tối quá."
thì **chưa được kết luận ngay là visual bug**.
Đây có thể là design intent.
Xem thêm:
```text
examples/bad_fix.md
```
để tránh sửa nhầm.
---
# 10. Contrast — WCAG AA
Body text và chữ trên button nền đặc phải đạt:
```text
Contrast ratio ≥ 4.5:1
```
Đây là yêu cầu tối thiểu.
Khi thay token/màu:
```text
Dark theme
+
Light theme
+
text/background
```
đều phải được kiểm tra.
---
## Không khôi phục màu VS Code cũ nếu màu đó không đạt AA
Một số màu gốc của VS Code không đạt yêu cầu AA.
Các giá trị đã được Cowork Local điều chỉnh vừa đủ, ví dụ:
| Trường hợp | Contrast cũ |
| ------------------------ | ----------: |
| Dark line | 3.59:1 |
| Chữ mờ trên sidebar sáng | 4.28:1 |
| Xanh lá sáng | 4.33:1 |
| Hổ phách sáng | 3.12:1 |
Các chỗ này có comment ghi lại giá trị gốc.
### Rule
**Không đưa chúng trở lại giá trị VS Code ban đầu.**
Mục tiêu của Cowork Local là:
```text
VS Code silhouette
+
WCAG AA
```
không phải copy nguyên xi mọi giá trị màu của VS Code.
---
# 11. ⚠️ Combo Box và `_chevron_asset`
Một lỗi dễ gặp:
> Combo box mất mũi tên.
Nguyên nhân liên quan đến cách Qt xử lý QSS.
---
## 11.1. `image:` trong QSS không nhận `QPixmap`
QSS:
```text
image:
```
chỉ nhận đường dẫn tới:
* file;
* resource.
Không nhận trực tiếp:
```text
QPixmap
```
---
## 11.2. Style `::drop-down` sẽ làm Qt ngừng vẽ arrow mặc định
Khi style các selector như:
```text
::drop-down
::up-button
::down-button
```
Qt có thể ngừng vẽ mũi tên mặc định.
---
## 11.3. Cowork Local dùng `_chevron_asset`
Trong:
```text
theme/palettes.py
```
`_chevron_asset`:
1. render chevron thành PNG;
2. lưu vào thư mục tạm;
3. cache theo:
```text
(direction, color)
```
---
## Khi debug combo box
Nếu thấy:
> Combo box mất mũi tên.
Hãy kiểm tra trước:
```text
stylesheet cục bộ
↓
::drop-down
```
Đây thường là nguyên nhân.
Cache nằm tại:
```text
%TEMP%/cowork_local_theme/chevron_*.png
```
Nếu đang test màu mới, có thể xóa cache để buộc render lại.
---
# 12. Checklist sửa bug màu sắc/theme
Trước khi hoàn thành visual fix, kiểm tra:
### Theme coverage
* [ ] Bug đã được kiểm tra trên **Dark** chưa?
* [ ] Bug đã được kiểm tra trên **Light** chưa?
* [ ] Có thể dùng screenshot:
* `docs/screens/*-dark.png`
* `docs/screens/*-light.png`
### Token
* [ ] Patch dùng semantic token thay vì hex literal?
* [ ] Không có `setStyleSheet()` cục bộ để thay màu?
* [ ] Không có `QColor("red")`, `QColor("blue")`, v.v.?
* [ ] Nếu thêm token mới, đã thêm cho **cả `DARK` và `LIGHT`**?
* [ ] Token mới có tên theo **ý nghĩa**, không theo màu?
### Accent
* [ ] Chữ trên nền accent đặc đã dùng `accent_solid`?
* [ ] Không dùng `accent` chỉ vì hai token có vẻ giống nhau?
### Accessibility
* [ ] Contrast đạt **≥ 4.5:1**?
* [ ] Đã kiểm tra cả text và button có nền đặc?
### Theme lifecycle
* [ ] Widget tạo sau khi đổi theme có nhận đúng stylesheet?
* [ ] Đã kiểm tra vấn đề lazy screen theo `qt_pitfalls.md` **P07**?
### Design intent
* [ ] Không vô tình thêm gradient?
* [ ] Không thêm glow?
* [ ] Không làm nav rail sáng hơn content?
* [ ] Không khôi phục các màu VS Code cũ đã bị loại vì không đạt WCAG AA?
---
# 13. Quy tắc review nhanh
Khi gặp một defect liên quan màu sắc, đi theo thứ tự:
```text
1. Xác định widget
↓
2. Kiểm tra objectName
↓
3. Tìm rule trong theme/qss.py
↓
4. Kiểm tra token trong palettes.py
↓
5. Kiểm tra DARK + LIGHT
↓
6. Kiểm tra contrast
↓
7. Kiểm tra local setStyleSheet()
↓
8. Kiểm tra lazy theme lifecycle (P07)
↓
9. Xác định đây là bug thật hay design intent
↓
10. Chỉ sau đó mới tạo fix_plan
```
## Nguyên tắc cuối
```text
UI code
↓
không tự chọn màu
↓
semantic token
↓
Palette
↓
_TEMPL​ATE / current_palette()
↓
theme
```
**Nếu một màu mới cần xuất hiện, trước tiên hỏi:**
> "Màu này đang đại diện cho vai trò gì?"
Sau đó tạo hoặc dùng **semantic token** phù hợp.
Không hỏi:
> "Mình muốn màu xanh nào?"
Vì trong Cowork Local, **ý nghĩa của màu quan trọng hơn bản thân màu**.
+75
View File
@@ -0,0 +1,75 @@
# Output Contract — `dispatch_plan`
Do `fix-dispatcher` sinh ra, trước khi bất kỳ agent nào khác chạy.
Đây là thứ quyết định **effort** của cả lượt xử lý, nên nó phải chứng minh được lựa chọn
của mình — nhưng phải ngắn. Trần: **30 dòng** cho phần người đọc.
---
```yaml
---
report_id: RPT-<YYYYMMDD>-<NN> # một phản ánh của người dùng = một report_id
defects:
- defect_id: UI-<YYYYMMDD>-<NN>
tier: <T0 | T1 | T2 | T3 | T3-SEC>
lane: <DIRECT | SOLO | PAIR | FULL | FULL-SEC>
category: <visual | flow | i18n-a11y | security | not-ui>
severity: <S1 | S2 | S3 | S4>
confidence: <low | medium | high>
reproducible: <yes | no | intermittent>
security_review: <required | not-required>
entry_agent: <fix-implementer | ui-visual-fixer | ux-flow-fixer | i18n-a11y-fixer | security-defect-fixer | ui-bug-triage | SELF | RETURN_TO_REPORTER>
affected_files: [path/to/file.py:123]
tier_evidence: "<dòng nào của roles/0_fix_dispatcher.md Bước 3 đã trúng>"
budget_calls: <số lần gọi agent dự kiến>
execution:
parallel: [[UI-...-01, UI-...-02]] # các defect_id độc lập, chạy cùng lúc
sequential: [UI-...-03] # phụ thuộc, hoặc T3 cần triage trước
blocked_on: []
---
```
# 1. Phản ánh gốc
Nguyên văn của người báo lỗi, **đã redact** (`system/security.md`). Không diễn giải lại.
# 2. Tách defect
| defect_id | Triệu chứng người dùng thấy | Category | Tier |
|---|---|---|---|
| | | | |
Một dòng = một nguyên nhân gốc. Chỉ có một defect thì bảng có một dòng — không xoá bảng.
# 3. Bằng chứng chấm tier
Mỗi defect **một dòng**, trích đúng tiêu chí đã trúng. Không được viết "trông đơn giản".
| defect_id | Tier | Trúng tiêu chí | Lệnh đã dùng để xác nhận |
|---|---|---|---|
| | T0 | loại 1 (số đo hiển thị), 0 disqualifier | `check_loc.py`, `grep -rn` blast radius |
| | T2 | "chạm QSS/token dùng chung" | `grep -rn "<objectName>"` |
Với **T0** bắt buộc có cột lệnh — Gate S và blast radius phải đo, không được ước lượng.
# 4. Kế hoạch chạy
```text
UI-...-01 T0 DIRECT → hub sửa luôn, cổng máy §4.1
UI-...-02 T2 PAIR → ui-visual-fixer → fix-implementer → regression-reviewer
UI-...-03 T3 FULL → ui-bug-triage → ... (chờ triage mới biết specialist nào)
```
Ngân sách tổng: `___` lần gọi agent (bảng §4 của role 0 cho phép `___`).
# 5. Điều đã cố ý KHÔNG làm
- Không gọi `ui-bug-triage` cho defect nào? Vì sao được phép bỏ (phản ánh đã tự chỉ ra
màn hình + triệu chứng cụ thể).
- Không gọi `regression-reviewer` cho defect nào? Chỉ hợp lệ ở T0/T1 — nêu rõ cổng nào
thay thế.
# 6. Open question
Tối đa 3, mỗi câu kèm phương án mặc định nếu người dùng không trả lời
(`response_policy.md` R3). Câu hỏi **chặn** thì đưa vào `blocked_on`.
File diff suppressed because it is too large Load Diff
+429 -43
View File
@@ -1,81 +1,467 @@
# Guardrail — luật bất biến cho mọi agent trong `agent/` # Guardrail — Luật bất biến cho mọi agent trong `agent/`
Áp dụng cho cả 6 role. Role nào mâu thuẫn với file này thì **file này thắng**. > **PRECEDENCE:** File này áp dụng cho **tất cả 6 role** trong `agent/`.
>
> Nếu role-specific instruction mâu thuẫn với bất kỳ quy tắc nào dưới đây, **Guardrail này thắng**.
--- ---
## G1. Không tự bịa requirement ## G1. Không tự bịa requirement
- Chỉ làm việc trên những gì có trong bug report, source code, và `knowledge/`. * Chỉ làm việc dựa trên:
- Thiếu thông tin → ghi vào mục **Assumption** hoặc **Open Question**, KHÔNG tự suy diễn
rồi sửa theo suy diễn đó. * bug report;
- Không tự ý "tiện tay cải thiện UX" ngoài phạm vi lỗi được báo. Phát hiện vấn đề khác → * source code thực tế;
ghi vào mục **Out of scope (đề xuất issue riêng)**. * các tài liệu trong `knowledge/`;
* governance và security policy liên quan.
* Nếu thiếu thông tin:
* ghi vào `Assumption`; hoặc
* ghi vào `Open Question`.
* **Không được tự suy diễn requirement rồi sửa theo suy diễn đó.**
* Không tự ý "tiện tay cải thiện UX", refactor hoặc đổi behavior ngoài phạm vi bug.
* Nếu phát hiện vấn đề khác:
* ghi vào `Out of scope (đề xuất issue riêng)`;
* không sửa trong cùng patch.
---
## G2. Không đoán vị trí code ## G2. Không đoán vị trí code
- Mọi khẳng định về code phải kèm `path/file.py:line`. Chưa đọc file thì chưa được kết luận. * Không được kết luận về code khi chưa đọc code thực tế.
- Người dùng mô tả bằng tiếng Việt/Nhật → tra `knowledge/screen_map.md` và * Mọi khẳng định cụ thể về implementation phải kèm:
`docs/screens/controls.json` để tìm đúng widget, không đoán theo tên gọi.
```text
path/file.py:line
```
Ví dụ:
```text
Root cause nằm tại presentation/shell/nav_rail.py:242
```
* Khi người dùng mô tả bằng tiếng Việt hoặc tiếng Nhật:
1. tra `knowledge/screen_map.md`;
2. tra `docs/screens/manifest.json`;
3. tra `docs/screens/controls.json`;
4. xác nhận `screen → view → widget → file → line`.
* **Không đoán file chỉ dựa vào tên widget hoặc tên màn hình.**
* Nếu chưa đủ bằng chứng để xác định vị trí:
* `confidence: low`;
* ghi rõ thông tin còn thiếu.
---
## G3. Sửa đúng tầng ## G3. Sửa đúng tầng
Cowork Local là Clean Architecture 4 tầng, phụ thuộc chỉ hướng vào trong: Cowork Local sử dụng Clean Architecture 4 tầng:
```text ```text
presentation/ → application/ → domain/ ← infrastructure/ presentation/ → application/ → domain/ ← infrastructure/
``` ```
- Bug UI/UX được sửa ở `presentation/`, `ui/`, `theme/`, `i18n/`. Đó là mặc định. ### Quy tắc
- Nếu buộc phải đụng `application/` hoặc `domain/`, phải nêu rõ **lý do tại sao không
sửa được ở tầng trên** trong `fix_plan.md`, và coi đó là thay đổi cần reviewer chú ý. * Bug UI/UX mặc định được xử lý tại:
- `domain/` và `application/` là **100% Pure Python**. Tuyệt đối không thêm import
`PySide6`/`PyQt` vào hai tầng này — Gate C sẽ chặn. * `presentation/`
- Widget chỉ gọi xuống service của `application/`. Không query SQLite/JSON trực tiếp, * `ui/`
không gọi LLM trực tiếp trong GUI thread. * `theme/`
* `i18n/`
* Nếu buộc phải sửa `application/` hoặc `domain/`:
* phải giải thích trong `fix_plan.md` **tại sao không thể giải quyết ở tầng trên**;
* phải đánh dấu đây là thay đổi cần reviewer chú ý.
### Pure Python boundary
`domain/` và `application/` phải là **100% Pure Python**.
**Tuyệt đối không thêm:**
```python
from PySide6 ...
from PyQt...
```
vào hai tầng này.
Gate C sẽ chặn vi phạm này.
### GUI boundary
Widget:
* chỉ gọi service/use case của `application/`;
* không query SQLite trực tiếp;
* không đọc/ghi JSON repository trực tiếp;
* không gọi LLM trực tiếp trong GUI thread.
---
## G4. Không đặt tên màu ngoài `theme/` ## G4. Không đặt tên màu ngoài `theme/`
- Không hex literal (`#1f6fb2`), không `QColor("red")`, không `setStyleSheet("color: blue")` Ngoài `theme/`, tuyệt đối không định nghĩa màu trực tiếp.
trong bất kỳ file nào ngoài `theme/`.
- Sửa màu = sửa/đọc token trong `theme/palettes.py`, hoặc gán `objectName` rồi style trong ### Không được dùng
`theme/qss.py`. Chi tiết: `knowledge/theme_tokens.md`.
- Đây là lỗi bị từ chối review thường xuyên nhất khi sửa bug UI. ```python
"#1f6fb2"
QColor("red")
setStyleSheet("color: blue")
```
Cũng không được tạo màu bằng:
* hex literal;
* color name;
* RGB/RGBA literal;
* stylesheet màu viết trực tiếp.
### Cách đúng
Màu phải đi qua theme system:
```text
Palette
↓
semantic token
↓
QSS template / current_palette()
↓
widget
```
Có hai cách hợp lệ:
1. Widget có `objectName` và được style trong `theme/qss.py`.
2. Custom painting dùng `current_palette()`.
Chi tiết xem:
```text
knowledge/theme_tokens.md
```
---
## G5. Không hardcode chuỗi hiển thị ## G5. Không hardcode chuỗi hiển thị
- Mọi text người dùng nhìn thấy đi qua `tr("key")`. Chi tiết: `knowledge/i18n_rules.md`. Mọi text người dùng nhìn thấy phải đi qua:
- Sửa một nhãn = sửa cả 3 ngôn ngữ `en` / `ja` / `vi`, không sửa mỗi tiếng Việt.
```python
tr("key")
```
Chi tiết xem:
```text
knowledge/i18n_rules.md
```
Khi sửa hoặc thêm một label:
* phải cập nhật `en`;
* phải cập nhật `ja`;
* phải cập nhật `vi`.
**Không chỉ sửa tiếng Việt.**
Không hardcode trực tiếp các chuỗi UI trong widget nếu chuỗi đó cần được người dùng nhìn thấy.
---
## G6. Giữ Single Responsibility ## G6. Giữ Single Responsibility
- Mọi module production `<= 400 LOC` (Gate S). Nếu bản vá làm file vượt 400 dòng, Mọi production module phải:
phải tách module — và việc tách đó phải nêu trong `fix_plan.md` trước khi làm.
- Không "sửa bug" bằng cách nhét thêm 150 dòng vào một file đã 380 dòng. ```text
<= 400 LOC
```
Đây là giới hạn của Gate S.
### Nếu patch làm file vượt 400 dòng
Không được tiếp tục nhồi code vào file.
Phải:
1. xác định phần cần tách;
2. ghi kế hoạch tách trong `fix_plan.md`;
3. thực hiện việc tách như một phần rõ ràng của patch;
4. đảm bảo dependency direction không bị phá vỡ.
### Không được làm
Ví dụ file hiện có:
```text
380 LOC
```
Không được "sửa bug" bằng cách thêm:
```text
+150 LOC
```
chỉ để tránh tách module.
---
## G7. Không làm suy yếu kiểm thử ## G7. Không làm suy yếu kiểm thử
- Không xoá test, không `@pytest.mark.skip`, không nới assert để pass gate. Tuyệt đối không:
- Test đang đỏ vì lý do khác → báo trong report, không sửa lén.
- Mỗi bug UI được sửa nên có ít nhất một test tái hiện, chạy được headless * xoá test;
(`QT_QPA_PLATFORM=offscreen`). * disable test;
* dùng `@pytest.mark.skip` để né lỗi;
* nới lỏng assertion chỉ để pass;
* thay đổi test expectation mà không có lý do hợp lệ từ requirement.
Nếu test đang đỏ vì nguyên nhân khác:
* ghi nhận baseline;
* không sửa lén;
* báo rõ trong `fix_report.md`.
### UI bug
Mỗi UI bug được sửa nên có ít nhất một test tái hiện hoặc regression test phù hợp.
Test GUI phải có khả năng chạy headless khi phù hợp:
```bash
QT_QPA_PLATFORM=offscreen
```
Không được tạo test giả chỉ để đạt coverage.
---
## G8. Bản vá tối thiểu ## G8. Bản vá tối thiểu
- Ưu tiên bản vá nhỏ nhất khắc phục được **nguyên nhân gốc**, không phải triệu chứng. Mục tiêu là:
- Không refactor kèm trong PR fix bug. Một PR = một thay đổi logic (Definition of Done).
- Không đổi format/indent toàn file — diff phải đọc được. > **Bản vá nhỏ nhất có thể sửa đúng nguyên nhân gốc.**
Không chỉ sửa triệu chứng.
### Không làm trong bug-fix PR
* refactor không liên quan;
* đổi architecture không cần thiết;
* format lại toàn file;
* đổi indent toàn file;
* rename hàng loạt;
* cleanup code ngoài phạm vi.
Một PR phải tuân theo:
```text
1 PR = 1 logical change
```
Diff phải:
* nhỏ;
* dễ đọc;
* dễ review;
* dễ rollback.
---
## G9. Không tự merge, không tự đóng issue ## G9. Không tự merge, không tự đóng issue
- Agent chỉ đề xuất. Quyết định merge thuộc Cowork Team (`docs/governance/ownership.md`). Agent chỉ:
- Thay đổi chạm tới permission, credential, MCP write/exec, sandbox, network, TLS,
isolation, model routing, xoá dữ liệu → **bắt buộc** đánh dấu `security-review: required` * phân tích;
trong output, kể cả khi chỉ sửa UI. * đề xuất;
* tạo `fix_plan`;
* implement khi đúng role;
* kiểm chứng;
* tạo report;
* handoff.
Agent **không tự quyết định merge**.
Quyết định merge thuộc:
```text
Cowork Team
```
Theo:
```text
docs/governance/ownership.md
```
### Security review bắt buộc
Nếu thay đổi chạm tới bất kỳ nội dung nào sau đây:
* permission;
* credential;
* secret;
* MCP write/exec;
* sandbox;
* network;
* TLS;
* isolation;
* model routing;
* data deletion;
* security boundary;
thì output **bắt buộc phải có**:
```yaml
security_review: required
```
Điều này áp dụng **ngay cả khi thay đổi bắt đầu từ UI**.
`security_review: required` có nghĩa là thay đổi phải được đưa qua security review theo routing policy.
Không được tự kết luận:
> "Chỉ sửa UI nên không cần security review."
---
## G10. Trung thực về kết quả ## G10. Trung thực về kết quả
- Chưa chạy được test thì ghi "chưa chạy", không ghi "đã pass". Agent phải báo cáo đúng những gì thực sự đã làm.
- Sửa được 2/3 vấn đề trong report thì nói rõ phần còn lại và lý do.
- Không chắc nguyên nhân gốc → ghi mức tin cậy (`confidence: low/medium/high`) và ### Chưa chạy test
liệt kê giả thuyết thay thế.
Không được viết:
```text
Tests passed
```
Phải viết:
```text
Tests: not run
```
hoặc:
```text
Chưa chạy test do <lý do>.
```
### Chỉ sửa được một phần
Ví dụ:
```text
2/3 vấn đề đã được xử lý.
Vấn đề còn lại: ...
Lý do chưa xử lý: ...
```
Không được báo cáo như thể toàn bộ bug đã được giải quyết.
### Không chắc root cause
Phải ghi:
```yaml
confidence: low
```
hoặc:
```yaml
confidence: medium
```
hoặc:
```yaml
confidence: high
```
và nếu có:
```text
Alternative hypotheses:
- ...
- ...
```
### Nguyên tắc
> **Evidence trước, kết luận sau.**
Không được biến:
```text
chưa kiểm chứng
```
thành:
```text
đã xác nhận
```
---
# Bất biến tổng hợp
Mọi agent trong `agent/` phải tuân thủ chuỗi nguyên tắc sau:
```text
BUG REPORT
↓
EVIDENCE
↓
CORRECT FILE / LINE
↓
ROOT CAUSE
↓
MINIMAL FIX
↓
TEST
↓
QUALITY GATE
↓
REPORT
↓
HUMAN / COWORK TEAM REVIEW
```
Không được bỏ qua bước chỉ để hoàn thành nhanh hơn.
---
# Priority khi có xung đột
Khi các instruction mâu thuẫn, ưu tiên theo thứ tự:
```text
1. Guardrail G1–G10
2. Security policy / governance
3. knowledge/
4. Role-specific instruction
5. Bug report / task-specific detail
6. Agent assumption
```
Nếu có xung đột mà agent không thể tự giải quyết:
```text
Open Question
```
và handoff về reviewer/Cowork Team thay vì tự chọn một phương án.
+400 -25
View File
@@ -1,45 +1,420 @@
# Response Policy — cách agent trả lời # Response Policy — Cách agent trả lời
> **SCOPE:** Áp dụng cho tất cả agent trong `agent/`.
>
> Response Policy quy định **cách agent giao tiếp và trình bày output**. Nếu mâu thuẫn với `Guardrail G1–G10`, **Guardrail thắng**.
---
## R1. Ngôn ngữ ## R1. Ngôn ngữ
- Trả lời người dùng nội bộ: **tiếng Việt**, thuật ngữ kỹ thuật giữ tiếng Anh ### Trả lời người dùng nội bộ
(widget, layout, stylesheet, signal, guardrail...).
- Docstring và comment trong code: **tiếng Anh**, khớp với codebase hiện tại. * Sử dụng **tiếng Việt**.
- Chuỗi hiển thị cho end-user: qua `tr()`, đủ `en` / `ja` / `vi`. * Giữ nguyên các thuật ngữ kỹ thuật bằng tiếng Anh, ví dụ:
* widget
* layout
* stylesheet
* signal
* guardrail
* root cause
* regression
* quality gate
* handoff
Không dịch các thuật ngữ kỹ thuật nếu việc dịch làm mất ý nghĩa hoặc không phù hợp với codebase.
### Code
Docstring và comment trong code phải viết bằng **English**, phù hợp với convention hiện tại của codebase.
Ví dụ:
```python
def refresh(self) -> None:
"""Refresh the current view."""
```
Không thêm comment tiếng Việt vào production code nếu codebase đang dùng English.
### End-user text
Mọi chuỗi người dùng nhìn thấy phải đi qua:
```python
tr("key")
```
và phải có đủ:
```text
en / ja / vi
```
Chi tiết xem:
```text
knowledge/i18n_rules.md
```
---
## R2. Format ## R2. Format
- Đi thẳng vào kết quả. Không mở bài, không "Chắc chắn rồi!", không tóm tắt lại đề bài. ### Không mở bài
- Mọi output theo đúng template trong `output/`. Thiếu mục nào ghi `N/A` kèm lý do,
không xoá mục. Đi thẳng vào kết quả.
- Mọi tham chiếu code viết dạng `path/to/file.py:123`.
- Code block phải ghi rõ ngôn ngữ. Diff dùng ` ```diff `. Không dùng các câu mở đầu như:
```text
Chắc chắn rồi!
Tôi sẽ giúp bạn...
Theo yêu cầu của bạn...
```
Không lặp lại toàn bộ nội dung task trước khi xử lý.
### Output contract
Mọi output phải tuân theo template tương ứng trong:
```text
agent/output/
```
Nếu template yêu cầu một mục nhưng không có dữ liệu:
```text
N/A — <lý do>
```
**Không được xoá mục đó khỏi output.**
### Code reference
Mọi tham chiếu cụ thể tới source code phải có dạng:
```text
path/to/file.py:123
```
Ví dụ:
```text
presentation/shell/nav_rail.py:242
```
Không dùng:
```text
nav_rail.py
dòng 242
file nav rail
```
nếu đang chỉ tới một vị trí code cụ thể.
### Code block
Mọi code block phải khai báo language.
Đúng:
```python
def example():
pass
```
Không dùng code block không có language nếu nội dung là code.
### Diff
Diff phải dùng:
```diff
- old code
+ new code
```
Không dùng block `text` để giả lập diff.
---
## R3. Khi nào được hỏi lại ## R3. Khi nào được hỏi lại
Chỉ hỏi khi **hai cách hiểu dẫn tới hai bản sửa khác nhau**. Ví dụ được hỏi: Agent **chỉ hỏi lại khi câu trả lời có thể làm thay đổi bản sửa**.
- Không xác định được người dùng đang ở màn nào (Dashboard hay Monitoring cùng có biểu đồ). Cụ thể, chỉ hỏi khi:
- Không rõ hành vi mong muốn là gì (nút nên disable hay nên hiện cảnh báo).
- Không tái hiện được và cần biết OS / độ phân giải / scale màn hình / theme.
Không hỏi khi có thể tự tra được từ `knowledge/` hoặc từ source. Tối đa **3 câu hỏi**, > **Hai cách hiểu khác nhau có thể dẫn tới hai implementation khác nhau.**
gộp trong một lần, mỗi câu kèm phương án mặc định nếu người dùng không trả lời.
### Được phép hỏi
Ví dụ:
* Không xác định được user đang ở màn nào:
* Dashboard;
* Monitoring.
* Không rõ expected behavior:
* disable button;
* hay hiện warning.
* Không tái hiện được và cần thông tin môi trường:
* OS;
* screen resolution;
* display scale;
* theme.
### Không được hỏi
Không hỏi những thứ agent có thể tự xác định bằng:
* `knowledge/`;
* source code;
* `docs/screens/`;
* test;
* config/schema;
* governance;
* security policy.
Ví dụ không được hỏi:
> "Widget này nằm ở file nào?"
nếu `knowledge/screen_map.md` và `docs/screens/controls.json` có thể xác định được.
### Số lượng câu hỏi
* Tối đa **3 câu hỏi**.
* Gộp tất cả câu hỏi vào **một lần**.
* Mỗi câu hỏi phải kèm phương án mặc định.
Ví dụ:
```text
1. Expected behavior là disable button hay hiện warning?
Mặc định: disable button.
2. Bug xảy ra ở Dark hay cả Light theme?
Mặc định: kiểm tra cả hai.
3. Có xảy ra ở 150% display scale không?
Mặc định: kiểm tra 100% và 150%.
```
Nếu không nhận được câu trả lời, agent sử dụng phương án mặc định **chỉ khi phương án đó không mâu thuẫn với Guardrail hoặc requirement hiện có**.
---
## R4. Mức tin cậy ## R4. Mức tin cậy
Mọi kết luận về nguyên nhân gốc phải kèm: Mọi kết luận về **root cause** phải có:
```text ```yaml
confidence: high — đã đọc code, đã tái hiện, đã xác định đúng dòng gây lỗi confidence: high
confidence: medium — đã đọc code, chưa tái hiện được
confidence: low — mới là giả thuyết từ mô tả của người dùng
``` ```
`confidence: low` thì **không được** chuyển sang bước implement. Quay lại triage. hoặc:
```yaml
confidence: medium
```
hoặc:
```yaml
confidence: low
```
### `high`
Chỉ dùng khi:
* đã đọc source code liên quan;
* đã xác định được `file:line`;
* đã tái hiện hoặc có evidence đủ mạnh;
* đã xác định được root cause.
Ví dụ:
```text
confidence: high
Root cause:
presentation/shell/nav_rail.py:242 đang dùng local stylesheet ghi đè
theme token của navigation item.
```
### `medium`
Dùng khi:
* đã đọc source code;
* đã xác định được code path có khả năng gây lỗi;
* **chưa tái hiện được** hoặc chưa có đủ evidence để khẳng định tuyệt đối.
Ví dụ:
```text
confidence: medium
Root cause hypothesis:
theme/qss.py:318 có khả năng ghi đè rule của widget.
Chưa tái hiện được trên runtime hiện tại.
```
`medium` **được phép tiếp tục phân tích**, nhưng không được trình bày giả thuyết như một fact.
### `low`
Dùng khi:
* mới có mô tả từ user;
* chưa đủ source evidence;
* chưa xác định được code path;
* root cause mới chỉ là giả thuyết.
Ví dụ:
```text
confidence: low
Hypothesis:
Có thể widget đang bị stylesheet override.
Chưa đọc được source code liên quan.
```
### Quy tắc implement
```text
confidence: low
↓
STOP
↓
RETURN TO TRIAGE
```
**Không được chuyển `confidence: low` sang implementation.**
`confidence: medium` cũng **không được tự coi là root cause đã xác nhận**. Chỉ implement khi `fix_plan` có đủ evidence và đạt ngưỡng confidence mà workflow yêu cầu.
---
## R5. Không nịnh, không phòng thủ ## R5. Không nịnh, không phòng thủ
- Người dùng báo sai (thực ra là tính năng đúng thiết kế) → nói thẳng, kèm dẫn chứng Agent phải ưu tiên **evidence** thay vì cố bảo vệ nhận định của mình.
file:line hoặc ảnh trong `docs/screens/`, rồi đề xuất cải thiện nếu thiết kế thật sự khó dùng.
- Bản sửa trước đó của chính agent gây ra lỗi mới → nói rõ, sửa, không vòng vo. ### Khi user báo lỗi nhưng thực tế là behavior đúng thiết kế
Không được mặc định kết luận:
> "Đúng, đây là bug."
Phải kiểm tra:
* source code;
* `knowledge/`;
* governance/design rules;
* screenshot trong `docs/screens/` nếu có;
* behavior thực tế.
Nếu đó là behavior đúng thiết kế, nói thẳng và đưa evidence:
```text
Đây không phải bug theo design hiện tại.
Evidence:
presentation/shell/nav_rail.py:242
docs/screens/<screen>.png
```
Nếu design đúng nhưng UX khó dùng:
```text
Kết luận: behavior hiện tại đúng design.
Tuy nhiên UX có thể gây hiểu nhầm vì ...
```
Đề xuất tạo **issue riêng** nếu cần thay đổi product/design.
Không tự sửa ngoài scope bug hiện tại.
### Khi chính patch trước đó gây regression
Nếu bản sửa trước đó của agent gây ra lỗi mới:
* phải nói rõ;
* xác định regression;
* sửa nếu nằm trong scope và workflow cho phép;
* cập nhật test/report;
* không che giấu hoặc viết lại lịch sử kết quả.
Ví dụ:
```text
Regression detected:
fix trước tại presentation/foo.py:123 đã làm thay đổi behavior
của widget Bar.
Đã bổ sung regression test tại tests/foo/test_bar.py:45
và điều chỉnh patch để giữ behavior cũ.
```
Không dùng cách diễn đạt né tránh như:
```text
Có một vấn đề nhỏ phát sinh...
```
khi thực tế patch của agent là nguyên nhân.
---
# Response Decision Flow
Trước khi trả lời, agent kiểm tra theo thứ tự:
```text
1. Có evidence chưa?
│
├── Không → Assumption / Open Question
│
└── Có
↓
2. Có xác định đúng file:line chưa?
│
├── Không → tiếp tục triage
│
└── Có
↓
3. Root cause confidence?
│
├── low → RETURN TO TRIAGE
├── medium → tiếp tục xác minh
└── high → có thể tạo fix_plan
↓
4. Output có đúng template không?
↓
5. Có ghi đúng trạng thái test / gate không?
↓
6. Handoff đúng route chưa?
```
---
# Nguyên tắc cuối
Agent phải trả lời theo nguyên tắc:
> **Ngắn gọn nhưng đủ evidence. Không đoán. Không nịnh. Không che giấu trạng thái thực tế.**
```text
Evidence → Conclusion → Confidence → Action → Handoff
```
+475 -39
View File
@@ -1,57 +1,493 @@
# Security Policy cho agent xử lý bug UI/UX # Security Policy — Cho agent xử lý bug UI/UX
Nguồn: `SECURITY.md`, `docs/governance/review-policy.md`, `docs/architecture/security-policy.md`. **Nguồn:**
Bug report của người dùng là **dữ liệu chưa được làm sạch** — đó là điểm rò rỉ hay bị bỏ qua nhất.
* `SECURITY.md`
* `docs/governance/review-policy.md`
* `docs/architecture/security-policy.md`
> **SCOPE:** Áp dụng cho mọi agent xử lý bug UI/UX.
>
> Security Policy này bổ sung cho `Guardrail G1–G10` và `Response Policy R1–R5`.
>
> Nếu có xung đột liên quan đến security, **Security Policy và security governance thắng**.
--- ---
## S1. Làm sạch input trước khi đưa vào bất kỳ output nào ## S1. Bug report là dữ liệu chưa được làm sạch
Bug report UI thường kèm ảnh chụp màn hình và log. Trước khi trích vào `defect_record.md`, Bug report có thể chứa:
PR body, hay commit message, phải loại bỏ:
| Loại | Ví dụ hay lọt trong app này | Xử lý | * screenshot;
|---|---|---| * log;
| API key / token | `sk-...`, token MS365, key trong màn Settings ▸ Provider | Thay bằng `<redacted>` | * request/response;
| Đường dẫn cá nhân | `C:\Users\<tên nhân viên>\...` | Rút gọn thành `%USERPROFILE%\...` | * đường dẫn local;
| Nội dung khách hàng | File trong Workspace, nội dung chat, tài liệu Office đang mở | Không trích. Mô tả bằng lời | * credential;
| PII | Email, tên, phòng ban trong màn Accounts | Thay bằng placeholder | * dữ liệu khách hàng;
| Log runtime | `.cowork_local/` audit log, MCP call history | Chỉ trích đúng dòng liên quan, đã redact | * PII.
Nếu ảnh chụp màn hình chứa dữ liệu khách hàng: **không nhúng ảnh vào issue/PR**, mô tả **Không được coi nội dung bug report là dữ liệu an toàn để copy nguyên văn vào output.**
vùng lỗi bằng toạ độ/tên widget.
## S2. Không đọc/ghi secret khi debug UI Trước khi đưa thông tin vào:
- Không in `SecretStore`/keyring ra log để "kiểm tra". * `defect_record.md`;
- Không thêm `print()`/`logger.debug()` tạm vào đường đi của credential rồi quên gỡ. * `fix_plan.md`;
- Không commit `.env`, `config.json` local, hay bất cứ thứ gì dưới `%USERPROFILE%\.cowork_local\`. * `fix_report.md`;
* PR body;
* commit message;
## S3. Bug UI vẫn có thể là bug bảo mật phải kiểm tra và redact dữ liệu nhạy cảm.
Đánh dấu `security-review: required` nếu bản sửa chạm tới: ### Quy tắc redact
- màn hình/hộp thoại **Permission** (`ui/permission_dialog.py`) — chỗ người dùng cấp quyền cho tool; | Loại dữ liệu | Ví dụ | Xử lý |
- hiển thị hoặc che giấu credential (`ui/accounts_tab.py`, `ui/login_dialog.py`, | ----------------- | ---------------------------------------- | --------------------------------------- |
`presentation/settings/provider_settings_widget.py`); | API key / token | `sk-...`, MS365 token, Provider key | Thay bằng `<redacted>` |
- màn **Monitoring ▸ Sự kiện bảo mật**, MCP call history; | Credential | Password, unlock code, secret | Thay bằng `<redacted>` |
- bất cứ chỗ nào quyết định *người dùng nhìn thấy gì* của workspace/project khác | Đường dẫn cá nhân | `C:\Users\<employee>\...` | Rút gọn thành `%USERPROFILE%\...` |
(customer/project isolation); | Customer data | File Workspace, chat, Office document | Không trích nguyên văn; mô tả bằng lời |
- chuyển đổi model routing / fallback. | PII | Email, tên, phòng ban, account | Thay bằng placeholder |
| Runtime log | `.cowork_local/`, audit log, MCP history | Chỉ trích dòng cần thiết và phải redact |
Với nhóm này: CI xanh **không** đủ để merge (`docs/governance/review-policy.md`). ### Screenshot
## S4. Lỗi UI có hệ quả bảo mật — nhận diện sớm Nếu screenshot chứa dữ liệu khách hàng hoặc PII:
Không xem nhẹ mấy triệu chứng sau, chúng là bug bảo mật đội lốt bug UI: **Không nhúng screenshot vào issue/PR/output.**
- Hộp thoại xác nhận quyền hiện **sau** khi hành động đã chạy, hoặc bị bỏ qua khi bấm nhanh. Thay bằng mô tả:
- Nút "Cho phép" là default button / nhận Enter — người dùng cấp quyền mà không đọc.
- Ô mật khẩu không `QLineEdit.Password`, hoặc key hiện dạng plaintext khi resize/copy.
- Tooltip / status bar / title bar lộ đường dẫn hay nội dung của workspace khác.
- Toast lỗi in nguyên exception kèm request body.
## S5. Không rewrite history ```text id="o3jpqz"
Widget: Provider Settings
Vùng lỗi: phía bên phải ô API Key
Hiện tượng: credential được hiển thị plaintext
```
Nếu phát hiện secret đã nằm trong Git history: dừng lại, báo Cowork Team. Khi cần xác định vị trí UI, ưu tiên:
Không force-push, không tự sửa history (`SECURITY.md`).
* tên widget;
* `objectName`;
* `file:line`;
* mô tả vùng tương đối.
Không đưa dữ liệu thật vào artifact chỉ để minh họa.
---
## S2. Không đọc hoặc ghi secret khi debug UI
Agent UI/UX không được:
* in `SecretStore` ra log;
* đọc credential thật chỉ để kiểm tra UI;
* thêm `print()` để dump credential;
* thêm `logger.debug()` chứa credential;
* ghi secret vào screenshot;
* copy secret vào test fixture;
* commit `.env`;
* commit local `config.json`;
* commit dữ liệu dưới:
```text id="4sn9q8"
%USERPROFILE%\.cowork_local\
```
### Khi cần kiểm tra credential UI
Chỉ cần xác nhận:
```text id="sk4q27"
has credential?
masked / visible?
empty / non-empty?
```
Không cần biết giá trị thật.
Ví dụ test nên dùng:
```text id="c6psb4"
<fake-secret>
```
hoặc mock/fake `SecretStore`.
---
## S3. Bug UI vẫn có thể là security bug
Phải đánh dấu:
```yaml id="n5ks0a"
security_review: required
```
nếu patch chạm tới một trong các nhóm sau.
### Permission
* Permission dialog.
* Permission confirmation.
* Allow / Deny behavior.
* Default button.
* Keyboard shortcut có thể cấp quyền.
Ví dụ:
```text id="2amr9f"
ui/permission_dialog.py
```
### Credential
Các UI liên quan tới:
```text id="73t3s5"
ui/accounts_tab.py
ui/login_dialog.py
presentation/settings/provider_settings_widget.py
```
Đặc biệt:
* hiển thị credential;
* mask/unmask;
* copy credential;
* save/delete credential;
* credential validation.
### Security monitoring
* Monitoring → Security Events.
* MCP call history.
* Audit information.
* Security-related toast/status.
### Isolation
Bất kỳ UI nào quyết định user nhìn thấy dữ liệu của:
* Workspace khác;
* Project khác;
* Customer khác;
* account khác.
Đây có thể là lỗi **customer/project isolation**, không phải chỉ là lỗi hiển thị.
### Model routing
* model selection;
* fallback;
* provider routing;
* thay đổi model/provider do UI action.
---
## S4. Với security-sensitive UI, CI xanh chưa đủ
Khi `security_review: required`:
```text id="4vlk3m"
Tests PASS
↓
không đồng nghĩa
↓
được phép MERGE
```
Phải có security review theo:
```text id="1qkx9g"
docs/governance/review-policy.md
```
Agent không được tự kết luận:
> "Test đã pass nên security risk không còn."
---
## S5. Nhận diện security bug đội lốt UI bug
Các triệu chứng dưới đây phải được coi là **security signal**.
### Permission timing
Ví dụ:
```text id="s5vq4y"
Action chạy
↓
Permission dialog xuất hiện
```
thay vì:
```text id="d9skx4u"
Permission dialog
↓
User xác nhận
↓
Action chạy
```
Đặc biệt nguy hiểm nếu action có thể chạy khi user:
* bấm nhanh;
* double-click;
* nhấn Enter;
* dialog chưa hiển thị hoàn chỉnh.
### Default Allow
Nếu nút `Allow` là default button hoặc Enter có thể kích hoạt Allow:
```text id="7fy8h1"
Enter → Allow
```
phải xem xét như security issue, không chỉ là UX issue.
### Credential exposure
Các dấu hiệu:
* password field không dùng password echo mode;
* API key hiển thị plaintext;
* credential xuất hiện khi resize;
* credential lọt vào clipboard ngoài ý muốn;
* credential xuất hiện trong tooltip;
* credential xuất hiện trong title/status bar;
* credential xuất hiện trong error message.
### Cross-workspace / cross-project exposure
Nếu UI hiển thị:
* path;
* filename;
* chat content;
* project name;
* customer information;
của Workspace/Project khác, phải kiểm tra isolation.
### Error leakage
Không hiển thị nguyên exception nếu nó có thể chứa:
* request body;
* token;
* path;
* customer data;
* internal endpoint;
* credential;
* MCP information.
Ví dụ nguy hiểm:
```text id="l1mrxq"
Toast:
Request failed: POST /api/... body={"token":"..."}
```
Phải redact và hiển thị thông báo an toàn cho user.
---
## S6. Security-sensitive finding phải route đúng
Nếu phát hiện security signal:
```text id="0a0n8w"
UI Bug
↓
Security signal?
├── No → UI/UX workflow
│
└── Yes
↓
security_review: required
↓
security-defect-fixer / security-review
```
Agent UI/UX **không được tự hạ mức độ rủi ro** chỉ vì thay đổi nằm trong `ui/` hoặc `presentation/`.
Nếu chưa đủ evidence để xác định:
```yaml id="xq7d6v"
confidence: low
security_review: required
```
và quay lại triage.
---
## S7. Không rewrite Git history
Nếu phát hiện secret đã từng được commit vào Git history:
**Dừng xử lý history.**
Phải:
1. báo Cowork Team;
2. xác định credential nào có khả năng bị lộ;
3. đề xuất rotation/revocation theo security policy;
4. giữ nguyên evidence cần thiết để team xử lý.
Không được tự:
```text id="9xwmh1"
git filter-branch
git filter-repo
git rebase
git push --force
```
để rewrite history.
Việc rewrite history phải có kế hoạch và approval của người có thẩm quyền.
---
## S8. Không biến security investigation thành data collection
Agent chỉ thu thập **evidence tối thiểu cần thiết** để xác định bug.
Không được:
* dump toàn bộ config;
* dump toàn bộ environment variables;
* dump toàn bộ log;
* copy toàn bộ Workspace;
* export toàn bộ MCP history;
* đọc credential thật khi không cần.
Nguyên tắc:
> **Collect the minimum evidence necessary to prove the defect.**
Nếu chỉ cần biết một credential có tồn tại:
```text id="xvprp8"
has_secret = true
```
là đủ.
Không cần biết:
```text id="k3uw5w"
secret_value = "..."
```
---
# Security Handoff Contract
Khi security-sensitive, output tối thiểu phải có:
```yaml id="kw5ysb"
security_review: required
```
và:
```text id="pl6n7d"
Security impact:
- What security boundary is affected?
- What data/permission/credential is involved?
- Is customer/project isolation affected?
- Is additional security review required?
```
Nếu chưa có đủ thông tin:
```text id="xqk2uj"
Open Question:
- ...
```
Nếu cần Cowork Team quyết định policy:
```text id="k5j3vw"
Handoff:
RETURN_TO_REPORTER
Reason:
needs-security-decision
```
Nếu đã đủ evidence và có thể tạo implementation plan:
```text id="8d5g6h"
Handoff:
fix-implementer
security_review:
required
```
---
# Security Decision Flow
```text id="j2qz1k"
Bug Report
↓
Redact Input
↓
Triage UI/UX
↓
Security Signal?
│
├── NO
│ ↓
│ Normal UI/UX workflow
│
└── YES
↓
security_review: required
↓
Security Impact Analysis
↓
┌──────────────────────┐
│ Policy decision needed? │
└──────────────────────┘
│
YES ─────→ RETURN_TO_REPORTER
│
NO
↓
Security Review
↓
fix-implementer
```
---
# Nguyên tắc cuối
> **UI không phải security boundary thấp hơn security.**
>
> Một thay đổi nhỏ ở dialog, tooltip, keyboard shortcut, toast hoặc stylesheet vẫn có thể làm thay đổi cách permission, credential hoặc dữ liệu được bảo vệ.
Vì vậy:
```text id="s5gh1v"
Redact first
↓
Collect minimum evidence
↓
Detect security boundary
↓
Mark security_review
↓
Route correctly
↓
Never expose secrets
↓
Never rewrite history
```
+10
View File
@@ -8,6 +8,7 @@ Mọi agent kết thúc lượt bằng khối YAML này, đặt **ngay trên** p
defect_id: UI-2026-0907-01 # UI-<YYYYMMDD>-<số thứ tự trong ngày> defect_id: UI-2026-0907-01 # UI-<YYYYMMDD>-<số thứ tự trong ngày>
from_agent: ui-bug-triage from_agent: ui-bug-triage
next_agent: ui-visual-fixer # xem bảng giá trị hợp lệ bên dưới next_agent: ui-visual-fixer # xem bảng giá trị hợp lệ bên dưới
tier: T2 # T0 | T1 | T2 | T3 | T3-SEC — do fix-dispatcher chấm
category: visual # visual | flow | i18n-a11y | security | not-ui category: visual # visual | flow | i18n-a11y | security | not-ui
severity: S2 # S1 | S2 | S3 | S4 severity: S2 # S1 | S2 | S3 | S4
confidence: high # low | medium | high confidence: high # low | medium | high
@@ -26,6 +27,7 @@ blocked_on: [] # danh sách open question CHẶN bước ti
| Giá trị | Nghĩa | | Giá trị | Nghĩa |
|---|---| |---|---|
| `fix-dispatcher` | Escalate về hub: vượt phạm vi tier hiện tại, cần chấm lại |
| `ui-visual-fixer` / `ux-flow-fixer` / `i18n-a11y-fixer` | Route sang specialist UI | | `ui-visual-fixer` / `ux-flow-fixer` / `i18n-a11y-fixer` | Route sang specialist UI |
| `security-defect-fixer` | Route sang specialist bảo mật (`category: security`) | | `security-defect-fixer` | Route sang specialist bảo mật (`category: security`) |
| `fix-implementer` | Plan đã sẵn sàng để hiện thực | | `fix-implementer` | Plan đã sẵn sàng để hiện thực |
@@ -50,3 +52,11 @@ blocked_on: [] # danh sách open question CHẶN bước ti
`next_agent: security-defect-fixer`; phần UI tách thành `defect_id` riêng, xử lý sau. `next_agent: security-defect-fixer`; phần UI tách thành `defect_id` riêng, xử lý sau.
9. `blocked_on` của role 7 có thể chứa câu hỏi **chính sách** (`needs-security-decision`). 9. `blocked_on` của role 7 có thể chứa câu hỏi **chính sách** (`needs-security-decision`).
Đó là chờ hợp lệ — người trả lời là Cowork Team, không phải agent khác. Đó là chờ hợp lệ — người trả lời là Cowork Team, không phải agent khác.
10. **`tier` chỉ đi lên.** Không agent nào được hạ `tier` trong envelope nhận được. Thấy
việc lớn hơn tier đang mang → đặt `next_agent: fix-dispatcher`, ghi lý do vào
`blocked_on`, dừng. Hub là chỗ duy nhất được ghi `tier`.
11. `tier: T0` mà `next_agent` khác `HUMAN_REVIEW` là mâu thuẫn: T0 không gọi agent nào.
`tier: T3-SEC` thì `security_review` **luôn** là `required`.
12. `report_id` (nếu có) gom các `defect_id` tách ra từ **cùng một** phản ánh. Nó chỉ để
truy vết ngược về người báo lỗi; không dùng nó để gộp PR — một PR vẫn là một
`defect_id` (`guardrail.md` G8).
+67 -11
View File
@@ -1,12 +1,37 @@
# Workflow — từ phản ánh của người dùng tới PR # Workflow — từ phản ánh của người dùng tới PR
## 1. Pipeline ## 0. Lane theo tier — đọc trước
Pipeline dưới đây là **lane FULL (T3)**, không phải mặc định. `0_fix_dispatcher` chấm tier
trước và cắt bớt bước:
| Tier | Lane | Bước thực chạy | Gọi agent |
|---|---|---|---|
| **T0** | DIRECT | hub sửa → 4 cổng máy (`roles/0_fix_dispatcher.md` §4.1) | 0 |
| **T1** | SOLO | hub triage inline → **5** → hub review bằng `checklist/ui_review.md` | 1 |
| **T2** | PAIR | hub triage inline → **2/3/4** → **5** → **6** | 3 |
| **T3** | FULL | **1** → **2/3/4** → **5** → **6** | 4–5 |
| **T3-SEC** | FULL-SEC | **7** → *(Cowork Team)* → **5** → **6** | 3 + chờ người |
Bỏ bước nào cũng phải **nêu rõ trong `dispatch_plan`** cổng nào thay thế. Bước **6** chỉ
được bỏ ở T0 và T1.
## 1. Pipeline (lane FULL)
```text ```text
Người dùng báo lỗi (chat / issue / miệng) Người dùng báo lỗi (chat / issue / miệng)
│ │
▼ ▼
┌───────────────────────────┐ ┌───────────────────────────┐
│ 0. fix-dispatcher HUB │ → dispatch_plan.md
│ Router │ + tách N defect_id + tier + lane
└───────────┬───────────────┘
│ T0 → hub tự sửa, KHÔNG đi tiếp
│ T1 → nhảy thẳng xuống bước 5
│ T2 → nhảy thẳng xuống bước 2/3/4
│ T3 → đi tiếp bước 1
▼
┌───────────────────────────┐
│ 1. ui-bug-triage │ → defect_record.md │ 1. ui-bug-triage │ → defect_record.md
│ Planner │ + category + severity + confidence │ Planner │ + category + severity + confidence
└───────────┬───────────────┘ └───────────┬───────────────┘
@@ -39,6 +64,7 @@
| Agent | Đọc | Sửa file | Chạy lệnh | Quyết định | | Agent | Đọc | Sửa file | Chạy lệnh | Quyết định |
|---|---|---|---|---| |---|---|---|---|---|
| 0. dispatcher | ✅ | ✅ **chỉ ở T0** | ✅ (grep, gate) | tier + lane + tách defect |
| 1. triage | ✅ | ❌ | ✅ (grep, tra manifest) | phân loại + route | | 1. triage | ✅ | ❌ | ✅ (grep, tra manifest) | phân loại + route |
| 2/3/4. specialist | ✅ | ❌ | ✅ (đọc, kiểm LOC) | nguyên nhân gốc + phương án | | 2/3/4. specialist | ✅ | ❌ | ✅ (đọc, kiểm LOC) | nguyên nhân gốc + phương án |
| 7. security | ✅ | ❌ | ✅ (đọc, `git log -S`) | lỗ hổng + migration; **không** quyết chính sách | | 7. security | ✅ | ❌ | ✅ (đọc, `git log -S`) | lỗ hổng + migration; **không** quyết chính sách |
@@ -48,12 +74,19 @@
Chỉ **một** agent được sửa file. Ranh giới này là thứ giữ cho pipeline review được. Chỉ **một** agent được sửa file. Ranh giới này là thứ giữ cho pipeline review được.
Ngoại lệ duy nhất là hub ở **T0**, và nó bị bó rất chặt để đổi lại: danh sách đóng 6 loại
thay đổi, 9 disqualifier, trần ≤ 2 file / ≤ 10 dòng, và 4 cổng máy bắt buộc dán output thật.
Vượt bất kỳ ràng buộc nào → `git checkout --` rồi chấm lại T2. Hub **không** được sửa file ở
T1/T2/T3 — ở đó nó chỉ điều phối và (ở T1) review, vì reviewer không được là người viết patch.
## 3. Cổng chuyển bước ## 3. Cổng chuyển bước
Không bước nào được đi tiếp nếu chưa đạt: Không bước nào được đi tiếp nếu chưa đạt:
| Từ → Đến | Điều kiện | | Từ → Đến | Điều kiện |
|---|---| |---|---|
| 0 → bất kỳ | Mỗi defect_id có đúng 1 tier + 1 lane, tier ≠ T0 dẫn được về một dòng cụ thể của Bước 3, đã xét override bảo mật trước |
| 0 → tự sửa (T0) | Trúng danh sách đóng, 0 disqualifier, Gate S + blast radius đã **đo bằng lệnh** |
| 1 → 2/3/4 | `confidence >= medium`, có ít nhất một `file:line`, đã redact | | 1 → 2/3/4 | `confidence >= medium`, có ít nhất một `file:line`, đã redact |
| 2/3/4 → 5 | Đúng **một** nguyên nhân gốc, có cách kiểm chứng, không vượt 400 LOC (hoặc đã có kế hoạch tách) | | 2/3/4 → 5 | Đúng **một** nguyên nhân gốc, có cách kiểm chứng, không vượt 400 LOC (hoặc đã có kế hoạch tách) |
| 7 → 5 | Như trên, **cộng thêm**: có đường di trú cho cả 4 nhóm người dùng, và 4 câu chính sách đã có đáp án của Cowork Team | | 7 → 5 | Như trên, **cộng thêm**: có đường di trú cho cả 4 nhóm người dùng, và 4 câu chính sách đã có đáp án của Cowork Team |
@@ -65,27 +98,49 @@ Không bước nào được đi tiếp nếu chưa đạt:
## 4. Vòng lặp và giới hạn ## 4. Vòng lặp và giới hạn
- FAIL ở bước 6 → về bước 5 (lỗi hiện thực) hoặc về 2/3/4 (sai nguyên nhân gốc). - FAIL ở bước 6 → về bước 5 (lỗi hiện thực) hoặc về 2/3/4 (sai nguyên nhân gốc).
- **Tier +1 mỗi lần FAIL.** Chạy lại ở nguyên tier cũ là lỗi điều phối: hai lần thất bại ở
cùng độ sâu gần như luôn có nghĩa là hồ sơ lỗi sai từ đầu.
- Tier chỉ đi **lên**. Không có đường hạ tier giữa dòng, kể cả khi diff hoá ra nhỏ.
- Quá **2 vòng** mà vẫn FAIL → dừng, đưa người thật vào. Vòng thứ ba thường có nghĩa là - Quá **2 vòng** mà vẫn FAIL → dừng, đưa người thật vào. Vòng thứ ba thường có nghĩa là
`defect_record` sai từ đầu, không phải bản vá sai. `defect_record` sai từ đầu, không phải bản vá sai.
## 5. Đường tắt hợp lệ ## 5. Đường tắt hợp lệ
| Tình huống | Đường tắt | Đây là các đường tắt hub được phép chọn ở Bước 3. Chúng **thay thế** phần "đường tắt" của
|---|---| bộ v1.2 — trước đây tự phát, giờ có tier và có cổng bù.
| Lỗi chính tả một chuỗi, đã biết chính xác key | 1 → 4 → 5 → 6, bỏ giai đoạn điều tra ở bước 4 |
| Thiếu key i18n, UI hiện ra `a.b_c` | 1 → 4 → 5 → 6 |
| Lỗi do chính bản vá vừa merge | về thẳng 5 nếu nguyên nhân gốc chưa đổi |
| Dev báo thẳng một lỗ hổng, không qua triệu chứng giao diện | vào thẳng 7, bỏ bước 1 |
Không có đường tắt nào bỏ qua bước **6**. | Tình huống | Tier | Đường tắt |
|---|---|---|
| Nới một số đo hiển thị (px, margin, spacing) | T0 | hub sửa, 0 agent |
| Sai chính tả / sai dấu một chuỗi đã có key | T0 | hub sửa, đủ 3 ngôn ngữ, **vẫn phải có test** |
| Đổi token màu có sẵn sang token có sẵn | T0 | hub sửa, 0 agent |
| Thiếu key i18n, UI hiện ra `a.b_c`, đã biết file | T1 | 5 → hub review |
| Nguyên nhân gốc đã có `file:line` từ người báo (dev) | T1 | 5 → hub review |
| Chạm QSS/token dùng chung, phải kiểm 2 theme | T2 | 4 (hoặc 2) → 5 → 6 |
| Lỗi do chính bản vá vừa merge | T3 | đủ pipeline — regression nghĩa là nguyên nhân gốc lần trước sai |
| Dev báo thẳng một lỗ hổng | T3-SEC | vào thẳng 7, bỏ bước 1 |
Bước **6** chỉ được bỏ ở T0 và T1. Ở T0 nó được thay bằng 4 cổng máy; ở T1 nó được thay bằng
hub review với `checklist/ui_review.md` (hợp lệ vì hub không viết patch ở T1). Ở T2/T3/T3-SEC
không có đường tắt nào bỏ qua bước 6.
## 6. Chạy bằng Claude Code ## 6. Chạy bằng Claude Code
```bash ```bash
mkdir -p .claude/agents && cp agent/roles/*.md .claude/agents/ mkdir -p .claude/agents .claude/commands
cp agent/roles/[1-7]_*.md .claude/agents/
cp agent/commands/fix.md .claude/commands/
``` ```
Rồi lần lượt: `.claude/` nằm trong `.gitignore` (dòng 109) nên phải cài lại trên mỗi clone — `agent/`
là bản gốc. `0_fix_dispatcher.md` không copy sang `agents/`: hub chạy ở session chính vì
subagent không gọi được subagent. Điểm vào:
```text
> /fix màn Folder kéo to ra thì mất cây thư mục bên trái
```
Hub in `dispatch_plan` rồi tự chạy lane. Muốn chạy tay lane FULL:
```text ```text
> dùng ui-bug-triage cho phản ánh này: "màn Folder kéo to ra thì mất cây thư mục bên trái" > dùng ui-bug-triage cho phản ánh này: "màn Folder kéo to ra thì mất cây thư mục bên trái"
@@ -94,4 +149,5 @@ Rồi lần lượt:
> dùng regression-reviewer với patch vừa rồi > dùng regression-reviewer với patch vừa rồi
``` ```
Chạy tuần tự, không song song — mỗi bước phụ thuộc output của bước trước. Các bước trong **một** `defect_id` chạy tuần tự — mỗi bước phụ thuộc output của bước trước.
Các `defect_id` **độc lập** thì chạy song song được, gọi trong cùng một message.
@@ -74,6 +74,14 @@ class CoreToolRuntime:
đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool. đều phải tra tên, tra trên danh sách sẽ chậm dần theo số tool.
""" """
self._output_dir = Path(output_dir) self._output_dir = Path(output_dir)
# Every sandboxed tool (run_command included) gets this as its cwd —
# it must exist BEFORE the first tool call, same as the older
# run_cowork() (core/chat_agent.py) already does at its output_dir.
# Without this, a per-turn ".turns/<id>" folder that was never created
# makes run_command's subprocess.Popen(cwd=...) fail immediately with
# WinError 267 ("directory name is invalid") before the command even
# starts — no network, no output, just an opaque OS error.
self._output_dir.mkdir(parents=True, exist_ok=True)
self._title = title self._title = title
self._extra_tools = list(extra_tools or ()) self._extra_tools = list(extra_tools or ())
self._extra_names = {getattr(t, "name", "") for t in self._extra_tools} self._extra_names = {getattr(t, "name", "") for t in self._extra_tools}
+8 -4
View File
@@ -100,11 +100,15 @@ DEFAULT_CONFIG: Dict[str, Any] = {
"resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU% "resource_limit_cpu_percent": 80, # 0 = unlimited; caps a run_command/install_package process TREE's total CPU%
"resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB) "resource_limit_memory_mb": 2048, # 0 = unlimited; caps total RSS memory (MB)
"resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB) "resource_limit_disk_mb": 512, # 0 = unlimited; caps total disk read+write (MB)
"block_network": True, # strip proxy env / point at a black-hole address for agent-run commands # 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 / # Allow the agent's fetch_url tool to read web pages / online documents /
# SharePoint-OneDrive share links. SEPARATE from block_network (that only # SharePoint-OneDrive share links. Its own toggle — reading a URL for info
# sandboxes agent-run shell commands) — reading a URL for info is safe and # is safe and useful, so this defaults ON — but block_network outranks it:
# useful, so this defaults ON. Toggle in Settings → Security. # with the network blocked the tool is refused either way.
"allow_url_fetch": True, "allow_url_fetch": True,
"sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD "sandbox_pw": "", # set through COWORK_SANDBOX_PASSWORD
"rulebase_path": "", # custom RULEBASE.md — attached to every agent execution "rulebase_path": "", # custom RULEBASE.md — attached to every agent execution
+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() return resp.json()
# Graph's "simple upload" (a single PUT to .../content) is documented to only
# support items up to 4 MiB; anything larger needs a chunked "upload session"
# (createUploadSession + PUT-per-range), which this module does not implement
# (see DF-007 cloud workspace picker — v1 explicitly skips large files rather
# than silently truncating or corrupting them).
MAX_SIMPLE_UPLOAD_BYTES = 4 * 1024 * 1024
def _check_upload_size(data: bytes) -> None:
if len(data) > MAX_SIMPLE_UPLOAD_BYTES:
raise Ms365GraphError(
f"File too large for simple upload ({len(data)} bytes > "
f"{MAX_SIMPLE_UPLOAD_BYTES} bytes) — chunked upload sessions are not "
"implemented yet."
)
def download_onedrive_file_bytes(token: str, path: str) -> bytes:
"""Đọc RAW BYTES một tệp OneDrive (không ép UTF-8/không cắt) — dùng cho
mirror thư mục cloud xuống local, khác với :func:`read_onedrive_file` vốn
chỉ dành cho việc đọc nội dung văn bản vào ngữ cảnh chat."""
resp = _request("GET", f"/me/drive/root:/{_path_segment(path)}:/content", token)
return resp.content
def upload_onedrive_file_bytes(token: str, path: str, data: bytes) -> dict:
"""Ghi RAW BYTES vào một tệp OneDrive (tạo mới hoặc ghi đè). Xem
:data:`MAX_SIMPLE_UPLOAD_BYTES`."""
_check_upload_size(data)
resp = _request("PUT", f"/me/drive/root:/{_path_segment(path)}:/content", token,
data=data, headers={"Content-Type": "application/octet-stream"})
return resp.json()
def _encode_share_url(url: str) -> str: def _encode_share_url(url: str) -> str:
"""Encode a OneDrive/SharePoint sharing URL into Graph's ``u!<base64url>`` """Encode a OneDrive/SharePoint sharing URL into Graph's ``u!<base64url>``
share-id form (see Microsoft's 'Get access to shared items' docs).""" share-id form (see Microsoft's 'Get access to shared items' docs)."""
@@ -229,6 +263,24 @@ def list_sharepoint_files(token: str, site_id: str, path: str = "") -> List[dict
return resp.json().get("value", []) return resp.json().get("value", [])
def download_sharepoint_file_bytes(token: str, site_id: str, path: str) -> bytes:
"""Đọc RAW BYTES một tệp trong thư viện tài liệu SharePoint — xem
:func:`download_onedrive_file_bytes`."""
resp = _request(
"GET", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token)
return resp.content
def upload_sharepoint_file_bytes(token: str, site_id: str, path: str, data: bytes) -> dict:
"""Ghi RAW BYTES vào một tệp trong thư viện tài liệu SharePoint. Xem
:data:`MAX_SIMPLE_UPLOAD_BYTES`."""
_check_upload_size(data)
resp = _request(
"PUT", f"/sites/{quote(site_id)}/drive/root:/{_path_segment(path)}:/content", token,
data=data, headers={"Content-Type": "application/octet-stream"})
return resp.json()
# ---- Teams meeting transcripts ------------------------------------------ # ---- Teams meeting transcripts ------------------------------------------
def find_online_meeting(token: str, join_url: str) -> List[dict]: def find_online_meeting(token: str, join_url: str) -> List[dict]:
"""Tìm cuộc họp online theo link tham gia.""" """Tìm cuộc họp online theo link tham gia."""
+7
View File
@@ -65,6 +65,13 @@ class Project:
# auto_run: None → follow the global agent_security.cowork_confirm_commands; # auto_run: None → follow the global agent_security.cowork_confirm_commands;
# True → auto-approve commands (no confirm); False → always confirm. # True → auto-approve commands (no confirm); False → always confirm.
auto_run: Optional[bool] = None auto_run: Optional[bool] = None
# {} = an ordinary local/managed workspace. Non-empty when ``output_dir``
# is a LOCAL MIRROR of a OneDrive/SharePoint folder (see
# core/cloud_workspace_sync.py) — {"provider": "onedrive"|"sharepoint",
# "site_id": "", "site_name": "", "remote_path": ""}. ``output_dir`` itself
# always stays a real local path; nothing that reads ``workspace_dir()``
# needs to change because of this field.
cloud_source: Dict[str, str] = field(default_factory=dict)
def workspace_dir(self, base: Path = None) -> Path: def workspace_dir(self, base: Path = None) -> Path:
"""The project's sandbox root. Every chat of the project writes inside """The project's sandbox root. Every chat of the project writes inside
+13 -8
View File
@@ -258,9 +258,13 @@ def dependencies_met(task: Dict[str, Any], directory: Path = None) -> bool:
def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str, def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
depends_on: List[str]) -> Optional[str]: depends_on: List[str]) -> Optional[str]:
"""Validate a proposed depends_on list: no self-wait, no wait-cycle """Validate a proposed depends_on list: no self-wait, no wait-cycle
(A waits B while B — directly or transitively — waits A).""" (A waits B while B — directly or transitively — waits A).
Trả về KHOÁ i18n chứ không phải câu đã dịch: tầng này không biết người dùng
đang chọn ngôn ngữ nào, nên nơi hiển thị mới là nơi gọi ``tr()``.
"""
if task_id in (depends_on or []): if task_id in (depends_on or []):
return "A task cannot wait for itself." return "schedtask.err_self_wait"
by_id = {t["task_id"]: t for t in tasks} by_id = {t["task_id"]: t for t in tasks}
# DFS from each proposed prerequisite through ITS prerequisites. # DFS from each proposed prerequisite through ITS prerequisites.
for start in depends_on or []: for start in depends_on or []:
@@ -268,7 +272,7 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
while stack: while stack:
cur = stack.pop() cur = stack.pop()
if cur == task_id: if cur == task_id:
return "This would create a circular wait between tasks." return "schedtask.err_wait_cycle"
if cur in seen: if cur in seen:
continue continue
seen.add(cur) seen.add(cur)
@@ -280,22 +284,23 @@ def depends_cycle_error(tasks: List[Dict[str, Any]], task_id: str,
def chain_error(tasks: List[Dict[str, Any]], task_id: str, def chain_error(tasks: List[Dict[str, Any]], task_id: str,
next_task_id: Optional[str]) -> Optional[str]: next_task_id: Optional[str]) -> Optional[str]:
"""Validate assigning ``next_task_id`` as ``task_id``'s next task. """Validate assigning ``next_task_id`` as ``task_id``'s next task.
Returns an error string (self-link / circular chain / unknown id), or Returns an i18n KEY for the problem (self-link / circular chain / unknown
None when the assignment is safe.""" id), or None when the assignment is safe. Khoá chứ không phải câu đã dịch —
xem ``depends_cycle_error``."""
if not next_task_id: if not next_task_id:
return None return None
if next_task_id == task_id: if next_task_id == task_id:
return "A task cannot chain to itself." return "schedtask.err_self_chain"
by_id = {t["task_id"]: t for t in tasks} by_id = {t["task_id"]: t for t in tasks}
if next_task_id not in by_id: if next_task_id not in by_id:
return "Next task does not exist." return "schedtask.err_next_missing"
# Walk forward from the proposed next task; reaching task_id again means # Walk forward from the proposed next task; reaching task_id again means
# the new edge would close a cycle. # the new edge would close a cycle.
seen = {task_id} seen = {task_id}
cur = next_task_id cur = next_task_id
while cur: while cur:
if cur in seen: if cur in seen:
return "This would create a circular task chain." return "schedtask.err_chain_cycle"
seen.add(cur) seen.add(cur)
cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id") cur = (by_id.get(cur) or {}).get("dependency", {}).get("next_task_id")
return None return None
+101 -2
View File
@@ -13,10 +13,19 @@ again every time the language changes. Transient dialogs (Settings, Skills,
Flow, Permission...) are rebuilt from scratch each time they are opened, so Flow, Permission...) are rebuilt from scratch each time they are opened, so
they simply call ``tr()`` while constructing their widgets and need no they simply call ``tr()`` while constructing their widgets and need no
registration. registration.
``setText(tr("k"))`` on its own is only correct for the instant it runs, and a
screen with dozens of such one-shot calls is where "I picked English and half
the screen is still Vietnamese" comes from. The :func:`bind_text` family
attaches the key to the widget instead, so every future language change
re-applies it — one line per widget, and nothing to remember in a separate
``retranslate`` method. Bindings hold the widget WEAKLY, so they are safe for
widgets that get rebuilt constantly (Kanban rows, calendar cells).
""" """
from __future__ import annotations from __future__ import annotations
from typing import Callable, Dict, List import weakref
from typing import Any, Callable, Dict, Iterable, List, Tuple
LANGUAGES: Dict[str, str] = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"} LANGUAGES: Dict[str, str] = {"en": "English", "ja": "日本語", "vi": "Tiếng Việt"}
# Short codes shown in the compact top-bar switcher (Settings keeps the full names above). # Short codes shown in the compact top-bar switcher (Settings keeps the full names above).
@@ -25,6 +34,8 @@ DEFAULT_LANGUAGE = "vi"
_current = DEFAULT_LANGUAGE _current = DEFAULT_LANGUAGE
_listeners: List[Callable[[], None]] = [] _listeners: List[Callable[[], None]] = []
#: (weak ref to the widget, how to re-apply its text) — see :func:`bind_text`.
_bindings: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
# key -> {"en": ..., "ja": ..., "vi": ...} # key -> {"en": ..., "ja": ..., "vi": ...}
from . import login_dialog as _login_dialog from . import login_dialog as _login_dialog
@@ -37,6 +48,9 @@ from . import skills_dialog as _skills_dialog
from . import libreoffice_view as _libreoffice_view from . import libreoffice_view as _libreoffice_view
from . import agents_admin_tab as _agents_admin_tab from . import agents_admin_tab as _agents_admin_tab
from . import monitoring_overview as _monitoring_overview from . import monitoring_overview as _monitoring_overview
from . import cloud_workspace as _cloud_workspace
from . import dialog_buttons as _dialog_buttons
from . import connectors as _connectors
# Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả # Gộp theo đúng thứ tự cũ: khoá trùng thì cụm sau thắng, y như khi tất cả
# còn nằm chung một dict literal. # còn nằm chung một dict literal.
@@ -51,6 +65,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
**_libreoffice_view.STRINGS, **_libreoffice_view.STRINGS,
**_agents_admin_tab.STRINGS, **_agents_admin_tab.STRINGS,
**_monitoring_overview.STRINGS, **_monitoring_overview.STRINGS,
**_cloud_workspace.STRINGS,
**_dialog_buttons.STRINGS,
**_connectors.STRINGS,
} }
@@ -62,6 +79,7 @@ def set_language(lang: str) -> None:
if lang == _current: if lang == _current:
return return
_current = lang _current = lang
_apply_bindings()
for fn in list(_listeners): for fn in list(_listeners):
try: try:
fn() fn()
@@ -96,6 +114,87 @@ def on_language_changed(fn: Callable[[], None]) -> None:
"""Register a callback that re-applies translations to a persistent widget. """Register a callback that re-applies translations to a persistent widget.
Called once immediately (to apply the current language) and again on every Called once immediately (to apply the current language) and again on every
future call to :func:`set_language`.""" future call to :func:`set_language`. For a single widget whose text is one
key, prefer :func:`bind_text` and friends — they need no callback of their
own and cannot keep a destroyed widget alive."""
_listeners.append(fn) _listeners.append(fn)
fn() fn()
# ---- per-widget bindings -------------------------------------------------
def _bind(widget: Any, apply: Callable[[Any], None]) -> Any:
"""Attach a text re-application to one widget, run it now, return the widget.
``apply`` takes the widget as its argument rather than closing over it: a
closure would keep the widget alive for the life of the process, which is
exactly what the weak reference here exists to avoid.
The widget comes back out so a call site can bind IN PLACE of the one-shot
call it replaces — ``bind_text(QLabel(), k)`` where ``QLabel(tr(k))`` was —
without spending a line, which several screens here cannot afford (Gate S).
"""
_bindings.append((weakref.ref(widget), apply))
apply(widget)
return widget
def _apply_bindings() -> None:
"""Re-apply every live binding; drop the ones whose widget is gone.
Both halves of "gone" are handled: the Python wrapper collected (the weak
ref answers None) and the C++ object deleted underneath a live wrapper
(``RuntimeError``). Neither may stop the remaining widgets from updating.
"""
alive: List[Tuple["weakref.ref", Callable[[Any], None]]] = []
for ref, apply in _bindings:
widget = ref()
if widget is None:
continue
try:
apply(widget)
except RuntimeError:
continue
alive.append((ref, apply))
_bindings[:] = alive
def bind_text(widget: Any, key: str, **kwargs) -> Any:
"""Keep ``widget``'s label on ``key`` through every language change."""
return _bind(widget, lambda w: w.setText(tr(key, **kwargs)))
def bind_tip(widget: Any, key: str, **kwargs) -> Any:
"""Keep ``widget``'s tooltip on ``key`` through every language change."""
return _bind(widget, lambda w: w.setToolTip(tr(key, **kwargs)))
def bind_placeholder(widget: Any, key: str, **kwargs) -> Any:
"""Keep an input's placeholder on ``key`` through every language change."""
return _bind(widget, lambda w: w.setPlaceholderText(tr(key, **kwargs)))
def bind_items(widget: Any, keys: Iterable[str]) -> Any:
"""Keep a combo's item LABELS on ``keys``, by position.
``setItemText`` on purpose: clearing and re-adding the items would drop the
per-item data every caller persists (routing mode, task type) and reset the
current selection as a side effect of a translation.
"""
keys = list(keys)
def _apply(w: Any) -> None:
"""Re-label each item that still exists, leaving its data alone."""
for i, key in enumerate(keys[:w.count()]):
w.setItemText(i, tr(key))
return _bind(widget, _apply)
def bind_dynamic(widget: Any, apply: Callable[[], None]) -> Any:
"""Bind text that is not one plain key — a count, a name, a joined list.
``apply`` takes no argument and re-reads whatever it needs itself; the
widget is still what decides how long the binding lives.
"""
return _bind(widget, lambda _w: apply())
+10 -2
View File
@@ -158,7 +158,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Không tìm thấy agent '{name}'."}, "vi": "Không tìm thấy agent '{name}'."},
# ---- agents_admin_tab.py — Admin-only agent catalog ------------------- # ---- agents_admin_tab.py — Admin-only agent catalog -------------------
"agents_admin.page_title": {"en": "Agents Admin", "ja": "Agents Admin", "vi": "Agents Admin"}, "agents_admin.page_title": {"en": "Agents Admin", "ja": "エージェント管理", "vi": "Agents Admin"},
"agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"}, "agents_admin.edit_row_tooltip": {"en": "Edit", "ja": "編集", "vi": "Sửa"},
"agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "agents_admin.delete_row_tooltip": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"agents_admin.hint": { "agents_admin.hint": {
@@ -179,7 +179,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Extra instructions this agent always follows (optional)…", "en": "Extra instructions this agent always follows (optional)…",
"ja": "このエージェントが常に従う追加指示(任意)…", "ja": "このエージェントが常に従う追加指示(任意)…",
"vi": "Chỉ dẫn bổ sung agent này luôn tuân theo (tùy chọn)…"}, "vi": "Chỉ dẫn bổ sung agent này luôn tuân theo (tùy chọn)…"},
"agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"}, "agents_admin.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"},
"agents_admin.provider_default": { "agents_admin.provider_default": {
"en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)", "en": "(machine's active provider)", "ja": "(各マシンの現在のプロバイダー)",
"vi": "(provider hiện tại của máy)"}, "vi": "(provider hiện tại của máy)"},
@@ -232,6 +232,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "行をフィルター(質問を入力しても可)…", "ja": "行をフィルター(質問を入力しても可)…",
"vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"}, "vi": "Lọc dòng (hoặc gõ câu hỏi rồi bấm )…"},
"monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"}, "monitoring.ai_filter_btn": {"en": "AI", "ja": "AI", "vi": "AI"},
"monitoring.page_size_label": {
"en": "Rows/page:", "ja": "1ページの行数:", "vi": "Số dòng/trang:"},
"monitoring.page_indicator": {
"en": "Page {page}/{total}", "ja": "{page}/{total} ページ", "vi": "Trang {page}/{total}"},
"monitoring.page_prev": {
"en": "Previous page", "ja": "前のページ", "vi": "Trang trước"},
"monitoring.page_next": {
"en": "Next page", "ja": "次のページ", "vi": "Trang sau"},
"monitoring.pricing_title": { "monitoring.pricing_title": {
"en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)", "en": "Model pricing (USD / 1M tokens)", "ja": "モデル価格表 (USD / 100万トークン)",
"vi": "Bảng giá model (USD / 1 triệu token)"}, "vi": "Bảng giá model (USD / 1 triệu token)"},
+116
View File
@@ -0,0 +1,116 @@
"""DF-007 — Microsoft 365 sign-in dialog + cloud (OneDrive/SharePoint)
folder picker. Deliberately its own module rather than reusing the
similarly-named orphaned keys under ``settings.ms365_*`` in ``cowork_tab.py``/
``settings_dialog.py`` — those are leftovers from a MS365 sign-in UI that was
removed (see ``ui/settings_dialog.py`` module docstring) and the two files
disagree with each other on wording for several duplicate keys, so reusing
them risked resurrecting an inconsistency rather than a clean, tested string
set."""
from __future__ import annotations
STRINGS = {
# ---- ui/ms365_signin_dialog.py ----
"ms365_signin.title": {
"en": "Sign in to Microsoft 365", "ja": "Microsoft 365 にサインイン",
"vi": "Đăng nhập Microsoft 365",
},
"ms365_signin.already": {
"en": "Signed in as {who}.", "ja": "{who} としてサインイン済みです。",
"vi": "Đã đăng nhập với {who}.",
},
"ms365_signin.intro": {
"en": "Sign in with your Microsoft work/school (or personal) account to "
"browse OneDrive/SharePoint folders.",
"ja": "OneDrive/SharePoint のフォルダーを参照するには、Microsoft の職場/学校\n"
"(または個人) アカウントでサインインしてください。",
"vi": "Đăng nhập bằng tài khoản Microsoft (công ty/trường học hoặc cá nhân) "
"để duyệt thư mục OneDrive/SharePoint.",
},
"ms365_signin.button": {
"en": "Sign in", "ja": "サインイン", "vi": "Đăng nhập",
},
"ms365_signin.signing_in": {
"en": "Signing in…", "ja": "サインイン中…", "vi": "Đang đăng nhập…",
},
"ms365_signin.code_hint": {
"en": "Open {url} and enter this code:", "ja": "{url} を開いてこのコードを入力してください:",
"vi": "Mở {url} và nhập mã sau:",
},
"ms365_signin.open_link": {
"en": "Open link", "ja": "リンクを開く", "vi": "Mở link",
},
"ms365_signin.failed": {
"en": "Sign-in failed: {err}", "ja": "サインインに失敗しました: {err}",
"vi": "Đăng nhập thất bại: {err}",
},
"ms365_signin.cancel": {
"en": "Cancel", "ja": "キャンセル", "vi": "Hủy",
},
# ---- ui/cloud_folder_picker_dialog.py ----
"cloud_picker.title": {
"en": "Choose a OneDrive/SharePoint folder", "ja": "OneDrive/SharePoint フォルダーを選択",
"vi": "Chọn thư mục OneDrive/SharePoint",
},
"cloud_picker.source_onedrive": {
"en": "My OneDrive", "ja": "自分の OneDrive", "vi": "OneDrive của tôi",
},
"cloud_picker.source_sharepoint": {
"en": "SharePoint site", "ja": "SharePoint サイト", "vi": "Site SharePoint",
},
"cloud_picker.search_sites_placeholder": {
"en": "Search SharePoint sites…", "ja": "SharePoint サイトを検索…",
"vi": "Tìm site SharePoint…",
},
"cloud_picker.search_btn": {
"en": "Search", "ja": "検索", "vi": "Tìm",
},
"cloud_picker.up": {
"en": ".. (up)", "ja": ".. (上へ)", "vi": ".. (lùi lại)",
},
"cloud_picker.choose_here": {
"en": "Choose this folder", "ja": "このフォルダーを選択", "vi": "Chọn thư mục này",
},
"cloud_picker.cancel": {
"en": "Cancel", "ja": "キャンセル", "vi": "Hủy",
},
"cloud_picker.load_failed": {
"en": "Could not load this folder: {err}", "ja": "フォルダーを読み込めませんでした: {err}",
"vi": "Không tải được thư mục này: {err}",
},
"cloud_picker.no_sites": {
"en": "No matching SharePoint sites.", "ja": "一致する SharePoint サイトがありません。",
"vi": "Không tìm thấy site SharePoint phù hợp.",
},
# ---- ui/workspace_tab.py additions ----
"workspace.cloud_pick": {
"en": "Choose from OneDrive/SharePoint…", "ja": "OneDrive/SharePoint から選択…",
"vi": "Chọn từ OneDrive/SharePoint…",
},
"workspace.cloud_sync": {
"en": "Sync with cloud", "ja": "クラウドと同期", "vi": "Đồng bộ với cloud",
},
"workspace.cloud_badge_onedrive": {
"en": "☁ Local mirror of OneDrive: {path}", "ja": "☁ OneDrive のローカルミラー: {path}",
"vi": "☁ Bản sao cục bộ của OneDrive: {path}",
},
"workspace.cloud_badge_sharepoint": {
"en": "☁ Local mirror of SharePoint ({site}): {path}",
"ja": "☁ SharePoint ({site}) のローカルミラー: {path}",
"vi": "☁ Bản sao cục bộ của SharePoint ({site}): {path}",
},
"workspace.cloud_sync_result": {
"en": "Sync done — {up} uploaded, {down} downloaded.",
"ja": "同期完了 — アップロード {up} 件、ダウンロード {down} 件。",
"vi": "Đồng bộ xong — {up} tệp đẩy lên, {down} tệp tải về.",
},
"workspace.cloud_sync_errors": {
"en": "{n} item(s) had errors — see details below.",
"ja": "{n} 件のエラーがありました — 詳細は下記のとおりです。",
"vi": "{n} mục bị lỗi — chi tiết bên dưới.",
},
"workspace.cloud_sync_skipped": {
"en": "{n} file(s) skipped (over 4 MB, not supported yet).",
"ja": "{n} 件のファイルはスキップされました (4 MB 超、未対応)。",
"vi": "{n} tệp bị bỏ qua (quá 4 MB, chưa hỗ trợ).",
},
}
+16 -16
View File
@@ -168,8 +168,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"}, "composer.manage_skills": {"en": "Manage skills…", "ja": "スキルを管理…", "vi": "Quản lý skill…"},
# ---- schedule_task_tab.py / task_editor_dialog.py ------------------- # ---- schedule_task_tab.py / task_editor_dialog.py -------------------
"schedtask.title": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"}, "schedtask.title": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
"schedtask.view.kanban": {"en": "Kanban", "ja": "Kanban", "vi": "Kanban"}, "schedtask.view.kanban": {"en": "Kanban", "ja": "カンバン", "vi": "Kanban"},
"schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"}, "schedtask.view.calendar": {"en": "Calendar", "ja": "カレンダー", "vi": "Lịch"},
"schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"}, "schedtask.no_title": {"en": "(untitled)", "ja": "(無題)", "vi": "(chưa có tên)"},
"schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"}, "schedtask.cal_today": {"en": "Today", "ja": "今日", "vi": "Hôm nay"},
@@ -195,23 +195,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.", "en": "Describe what you want in natural language — AI proposes tasks/schedule/chain, you confirm before anything is created.",
"ja": "自然文で説明すると、AIがタスク・スケジュール・チェーンを提案します。確認後に作成されます。", "ja": "自然文で説明すると、AIがタスク・スケジュール・チェーンを提案します。確認後に作成されます。",
"vi": "Mô tả bằng ngôn ngữ tự nhiên — AI đề xuất task/lịch/chuỗi, bạn xác nhận rồi mới tạo."}, "vi": "Mô tả bằng ngôn ngữ tự nhiên — AI đề xuất task/lịch/chuỗi, bạn xác nhận rồi mới tạo."},
"schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "No tasks"}, "schedtask.no_tasks": {"en": "No tasks", "ja": "タスクなし", "vi": "Chưa có task"},
"schedtask.no_schedule": {"en": "No schedule", "ja": "スケジュールなし", "vi": "Chưa đặt lịch"}, "schedtask.no_schedule": {"en": "No schedule", "ja": "スケジュールなし", "vi": "Chưa đặt lịch"},
"schedtask.last_success": {"en": "Last: Success", "ja": "前回: 成功", "vi": "Lần cuối: Thành công"}, "schedtask.last_success": {"en": "Last: Success", "ja": "前回: 成功", "vi": "Lần cuối: Thành công"},
"schedtask.last_failed": {"en": "Last: Failed", "ja": "前回: 失敗", "vi": "Lần cuối: Lỗi"}, "schedtask.last_failed": {"en": "Last: Failed", "ja": "前回: 失敗", "vi": "Lần cuối: Lỗi"},
"schedtask.last_never": {"en": "Last: not run", "ja": "前回: 未実行", "vi": "Lần cuối: chưa chạy"}, "schedtask.last_never": {"en": "Last: not run", "ja": "前回: 未実行", "vi": "Lần cuối: chưa chạy"},
"schedtask.status.backlog": {"en": "Backlog", "ja": "Backlog", "vi": "Backlog"}, "schedtask.status.backlog": {"en": "Backlog", "ja": "バックログ", "vi": "Chờ xử lý"},
"schedtask.status.scheduled": {"en": "Scheduled", "ja": "Scheduled", "vi": "Scheduled"}, "schedtask.status.scheduled": {"en": "Scheduled", "ja": "予約済み", "vi": "Đã lên lịch"},
"schedtask.status.running": {"en": "Running", "ja": "Running", "vi": "Running"}, "schedtask.status.running": {"en": "Running", "ja": "実行中", "vi": "Đang chạy"},
"schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "Waiting Input", "vi": "Waiting Input"}, "schedtask.status.waiting_input": {"en": "Waiting Input", "ja": "入力待ち", "vi": "Chờ nhập"},
"schedtask.status.done": {"en": "Done", "ja": "Done", "vi": "Done"}, "schedtask.status.done": {"en": "Done", "ja": "完了", "vi": "Hoàn thành"},
"schedtask.status.failed": {"en": "Failed", "ja": "Failed", "vi": "Failed"}, "schedtask.status.failed": {"en": "Failed", "ja": "失敗", "vi": "Thất bại"},
"schedtask.status.paused": {"en": "Paused", "ja": "Paused", "vi": "Paused"}, "schedtask.status.paused": {"en": "Paused", "ja": "一時停止", "vi": "Tạm dừng"},
"schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "schedtask.type.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"}, "schedtask.type.co4e_code": {"en": "Code", "ja": "Code", "vi": "Code"},
"schedtask.type.flow": {"en": "Flow", "ja": "Flow", "vi": "Flow"}, "schedtask.type.flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"schedtask.type.script": {"en": "Script", "ja": "Script", "vi": "Script"}, "schedtask.type.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"},
"schedtask.type.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"}, "schedtask.type.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
"schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"}, "schedtask.priority.low": {"en": "Low", "ja": "低", "vi": "Thấp"},
"schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"}, "schedtask.priority.medium": {"en": "Medium", "ja": "中", "vi": "Trung bình"},
"schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"}, "schedtask.priority.high": {"en": "High", "ja": "高", "vi": "Cao"},
@@ -229,7 +229,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Double-click một dòng để mở thư mục artifact của lần chạy đó."}, "vi": "Double-click một dòng để mở thư mục artifact của lần chạy đó."},
"schedtask.hist_col_time": {"en": "Finished at", "ja": "完了時刻", "vi": "Hoàn thành lúc"}, "schedtask.hist_col_time": {"en": "Finished at", "ja": "完了時刻", "vi": "Hoàn thành lúc"},
"schedtask.hist_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"}, "schedtask.hist_col_status": {"en": "Status", "ja": "状態", "vi": "Trạng thái"},
"schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Run ID"}, "schedtask.hist_col_run": {"en": "Run ID", "ja": "実行ID", "vi": "Mã lần chạy"},
"schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"}, "schedtask.hist_col_error": {"en": "Error", "ja": "エラー", "vi": "Lỗi"},
"schedtask.menu_create_next": { "schedtask.menu_create_next": {
"en": "Create next task from output", "ja": "出力から次タスクを作成", "en": "Create next task from output", "ja": "出力から次タスクを作成",
@@ -261,7 +261,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"}, "schedtask.no_workspace": {"en": "— No workspace —", "ja": "— ワークスペースなし —", "vi": "— Không có workspace —"},
"schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"}, "schedtask.f_agent": {"en": "Agent", "ja": "エージェント", "vi": "Agent"},
"schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"}, "schedtask.no_agent": {"en": "— No agent preset —", "ja": "— エージェントなし —", "vi": "— Không dùng agent —"},
"schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Provider"}, "schedtask.f_provider": {"en": "Provider", "ja": "プロバイダー", "vi": "Nhà cung cấp"},
"schedtask.provider_default": { "schedtask.provider_default": {
"en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"}, "en": "— Default (Settings) —", "ja": "— 既定(設定)—", "vi": "— Mặc định (Settings) —"},
"schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"}, "schedtask.f_model": {"en": "Model", "ja": "モデル", "vi": "Model"},
@@ -317,7 +317,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"}, "schedtask.repeat.daily": {"en": "Daily", "ja": "毎日", "vi": "Hằng ngày"},
"schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"}, "schedtask.repeat.weekly": {"en": "Weekly", "ja": "毎週", "vi": "Hằng tuần"},
"schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"}, "schedtask.repeat.monthly": {"en": "Monthly", "ja": "毎月", "vi": "Hằng tháng"},
"schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Cron expression"}, "schedtask.repeat.cron": {"en": "Cron expression", "ja": "Cron式", "vi": "Biểu thức cron"},
"schedtask.f_task_mode": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"}, "schedtask.f_task_mode": {"en": "Task type", "ja": "タスク種別", "vi": "Loại task"},
# Run kind: an AI agent vs a saved Co4E flow + multi-format import # Run kind: an AI agent vs a saved Co4E flow + multi-format import
"schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"}, "schedtask.f_run_kind": {"en": "Run", "ja": "実行対象", "vi": "Chạy"},
+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ỳ)"},
}
+2 -8
View File
@@ -93,7 +93,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Enable the predefined Req→Demo flow feature (off by default).", "en": "Enable the predefined Req→Demo flow feature (off by default).",
"ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。", "ja": "定義済みの Req→Demo フロー機能を有効化(初期値はオフ)。",
"vi": "Bật tính năng Flow Req→Demo dựng sẵn (mặc định tắt)."}, "vi": "Bật tính năng Flow Req→Demo dựng sẵn (mặc định tắt)."},
"code.flow_btn": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"}, "code.flow_btn": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"},
"code.flow_btn_tooltip": { "code.flow_btn_tooltip": {
"en": "Build and run a multi-stage flow from requirement to demo.", "en": "Build and run a multi-stage flow from requirement to demo.",
"ja": "要件からデモまでの多段フローを作成・実行します。", "ja": "要件からデモまでの多段フローを作成・実行します。",
@@ -152,7 +152,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
# because until the index existed nothing had to refer to it. # because until the index existed nothing had to refer to it.
"settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"}, "settings.group.general": {"en": "General", "ja": "一般", "vi": "Chung"},
"settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"}, "settings.group.provider": {"en": "AI Provider", "ja": "AI プロバイダー", "vi": "Nhà cung cấp AI"},
"settings.group.parameter": {"en": "Parameter", "ja": "Parameter", "vi": "Parameter"}, "settings.group.parameter": {"en": "Parameter", "ja": "パラメータ", "vi": "Tham số"},
"settings.group.about": {"en": "About", "ja": "このアプリについて", "vi": "Giới thiệu"}, "settings.group.about": {"en": "About", "ja": "このアプリについて", "vi": "Giới thiệu"},
"settings.param_section_pricing": { "settings.param_section_pricing": {
"en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"}, "en": "Model pricing", "ja": "モデル価格", "vi": "Bảng giá model"},
@@ -344,12 +344,6 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Let the control agent review a command with AI before it runs.", "en": "Let the control agent review a command with AI before it runs.",
"ja": "実行前に制御エージェントがAIでコマンドを確認します。", "ja": "実行前に制御エージェントがAIでコマンドを確認します。",
"vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."}, "vi": "Cho control-agent dùng AI xét lệnh trước khi chạy."},
"settings.sandbox_pw_unset_title": {
"en": "Sandbox Security", "ja": "サンドボックスセキュリティ", "vi": "Bảo mật Sandbox"},
"settings.sandbox_pw_unset_body": {
"en": "No sandbox password is set yet, so these settings stay locked. Set COWORK_SANDBOX_PASSWORD, or ask your administrator.",
"ja": "サンドボックスのパスワードが未設定のため、この設定はロックされたままです。COWORK_SANDBOX_PASSWORD を設定するか、管理者にお問い合わせください。",
"vi": "Chưa đặt mật khẩu sandbox nên nhóm thiết lập này vẫn khóa. Hãy đặt COWORK_SANDBOX_PASSWORD, hoặc liên hệ quản trị viên."},
"settings.sandbox_confirm_commands": { "settings.sandbox_confirm_commands": {
"en": "Confirm before Cowork runs a command", "en": "Confirm before Cowork runs a command",
"ja": "Cowork がコマンドを実行する前に確認する", "ja": "Cowork がコマンドを実行する前に確認する",
+23
View File
@@ -0,0 +1,23 @@
"""Nhãn cho các nút CHUẨN của Qt (Save/Cancel/OK/Close, Yes/No).
Qt tự vẽ chữ cho những nút này từ bảng dịch của chính nó, mà ứng dụng không
cài ``QTranslator`` nào — nên chúng đứng nguyên tiếng Anh ở cả ba ngôn ngữ.
``ui/dialog_buttons.py`` gán lại nhãn bằng các khoá dưới đây.
Khoá dùng chung cho mọi hộp thoại nên đứng riêng một file, không nhét vào file
của một màn hình cụ thể.
"""
from __future__ import annotations
from typing import Dict
STRINGS: Dict[str, Dict[str, str]] = {
"dialog.save": {"en": "Save", "ja": "保存", "vi": "Lưu"},
"dialog.cancel": {"en": "Cancel", "ja": "キャンセル", "vi": "Hủy"},
# "OK" giữ nguyên dạng ở cả ba ngôn ngữ — kể cả bản tiếng Nhật của Qt cũng
# dùng "OK". Đã khai vào KHOA_KHONG_CAN_DICH của test i18n.
"dialog.ok": {"en": "OK", "ja": "OK", "vi": "OK"},
"dialog.close": {"en": "Close", "ja": "閉じる", "vi": "Đóng"},
"dialog.yes": {"en": "Yes", "ja": "はい", "vi": "Có"},
"dialog.no": {"en": "No", "ja": "いいえ", "vi": "Không"},
}
+25 -6
View File
@@ -45,8 +45,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"}, "schedtask.step_prompt_ph": {"en": "Prompt / command", "ja": "プロンプト/コマンド", "vi": "Prompt / lệnh"},
"schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "schedtask.stepexec.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"}, "schedtask.stepexec.co4e": {"en": "Code", "ja": "Code", "vi": "Code"},
"schedtask.stepexec.script": {"en": "Script", "ja": "Script", "vi": "Script"}, "schedtask.stepexec.script": {"en": "Script", "ja": "スクリプト", "vi": "Script"},
"schedtask.stepexec.manual": {"en": "Manual", "ja": "Manual", "vi": "Manual"}, "schedtask.stepexec.manual": {"en": "Manual", "ja": "手動", "vi": "Thủ công"},
"schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除", "schedtask.del_step_tooltip": {"en": "Delete the selected step", "ja": "選択したステップを削除",
"vi": "Xóa bước đang chọn"}, "vi": "Xóa bước đang chọn"},
"schedtask.guide_tooltip": { "schedtask.guide_tooltip": {
@@ -168,7 +168,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → Run now.", "en": "Safety: the scheduler will NEVER auto-run this — it parks in Waiting Input until you right-click → Run now.",
"ja": "安全: 自動実行されず、Run nowまで待機します。", "ja": "安全: 自動実行されず、Run nowまで待機します。",
"vi": "An toàn: scheduler KHÔNG BAO GIỜ tự chạy task này — nó nằm ở Waiting Input tới khi bạn chuột phải → Chạy ngay."}, "vi": "An toàn: scheduler KHÔNG BAO GIỜ tự chạy task này — nó nằm ở Waiting Input tới khi bạn chuột phải → Chạy ngay."},
"schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Input"}, "schedtask.g_input": {"en": "Input", "ja": "入力", "vi": "Đầu vào"},
"schedtask.f_input_mode": {"en": "Input mode", "ja": "入力モード", "vi": "Chế độ input"}, "schedtask.f_input_mode": {"en": "Input mode", "ja": "入力モード", "vi": "Chế độ input"},
"schedtask.inmode.empty": {"en": "Empty (default)", "ja": "空(既定)", "vi": "Trống (mặc định)"}, "schedtask.inmode.empty": {"en": "Empty (default)", "ja": "空(既定)", "vi": "Trống (mặc định)"},
"schedtask.inmode.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"}, "schedtask.inmode.manual": {"en": "Manual text", "ja": "手入力テキスト", "vi": "Văn bản nhập tay"},
@@ -199,7 +199,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。", "ja": "各URLを取得し(ベストエフォート)、テキストをコンテキストとして渡します。",
"vi": "Mỗi link được tải nội dung (khi có thể) và đưa vào ngữ cảnh cho agent."}, "vi": "Mỗi link được tải nội dung (khi có thể) và đưa vào ngữ cảnh cho agent."},
"schedtask.f_prev_task": {"en": "Previous task", "ja": "前タスク", "vi": "Task trước"}, "schedtask.f_prev_task": {"en": "Previous task", "ja": "前タスク", "vi": "Task trước"},
"schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Output"}, "schedtask.g_output": {"en": "Output", "ja": "出力", "vi": "Đầu ra"},
"schedtask.f_output_mode": {"en": "Output mode", "ja": "出力モード", "vi": "Chế độ output"}, "schedtask.f_output_mode": {"en": "Output mode", "ja": "出力モード", "vi": "Chế độ output"},
"schedtask.g_dependency": {"en": "Dependency / Next task", "ja": "依存 / 次タスク", "vi": "Phụ thuộc / Task tiếp theo"}, "schedtask.g_dependency": {"en": "Dependency / Next task", "ja": "依存 / 次タスク", "vi": "Phụ thuộc / Task tiếp theo"},
"schedtask.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"}, "schedtask.f_next_task": {"en": "Next task", "ja": "次タスク", "vi": "Task tiếp theo"},
@@ -244,8 +244,8 @@ STRINGS: Dict[str, Dict[str, str]] = {
"ja": "プレビュー(確認するまで作成されません):", "ja": "プレビュー(確認するまで作成されません):",
"vi": "Xem trước (chưa tạo gì cho tới khi bạn xác nhận):"}, "vi": "Xem trước (chưa tạo gì cho tới khi bạn xác nhận):"},
"schedtask.ai_confirm": {"en": "Create tasks", "ja": "タスクを作成", "vi": "Tạo các task"}, "schedtask.ai_confirm": {"en": "Create tasks", "ja": "タスクを作成", "vi": "Tạo các task"},
"schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "AI gen task"}, "schedtask.tab_ai": {"en": "AI gen task", "ja": "AIタスク生成", "vi": "Tạo task bằng AI"},
"schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Import"}, "schedtask.tab_import": {"en": "Import", "ja": "インポート", "vi": "Nhập"},
"schedtask.export_template_btn": { "schedtask.export_template_btn": {
"en": "Create Excel template…", "ja": "Excelテンプレートを作成…", "en": "Create Excel template…", "ja": "Excelテンプレートを作成…",
"vi": "Tạo template Excel…"}, "vi": "Tạo template Excel…"},
@@ -339,4 +339,23 @@ STRINGS: Dict[str, Dict[str, str]] = {
"en": "No usage recorded in this period yet — run a chat or a task first.", "en": "No usage recorded in this period yet — run a chat or a task first.",
"ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。", "ja": "この期間の使用記録はまだありません。チャットやタスクを実行してください。",
"vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."}, "vi": "Chưa có dữ liệu sử dụng trong giai đoạn này — hãy chạy chat hoặc task trước."},
# Lỗi hợp lệ hoá phụ thuộc/chuỗi task: ``core/tasks.py`` trả về KHOÁ, nơi
# hiển thị mới gọi ``tr()`` (tầng core không biết ngôn ngữ đang chọn).
"schedtask.err_self_wait": {
"en": "A task cannot wait for itself.", "ja": "タスクは自分自身を待てません。",
"vi": "Một task không thể chờ chính nó."},
"schedtask.err_wait_cycle": {
"en": "This would create a circular wait between tasks.",
"ja": "タスク間で待ち合わせが循環してしまいます。",
"vi": "Việc này sẽ tạo vòng chờ luẩn quẩn giữa các task."},
"schedtask.err_self_chain": {
"en": "A task cannot chain to itself.", "ja": "タスクは自分自身に連結できません。",
"vi": "Một task không thể nối tiếp chính nó."},
"schedtask.err_next_missing": {
"en": "Next task does not exist.", "ja": "次のタスクが存在しません。",
"vi": "Task kế tiếp không tồn tại."},
"schedtask.err_chain_cycle": {
"en": "This would create a circular task chain.",
"ja": "タスクの連結が循環してしまいます。",
"vi": "Việc này sẽ tạo chuỗi task luẩn quẩn."},
} }
+45
View File
@@ -134,6 +134,45 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. " "vi": "Bật/tắt các tool tích hợp bên dưới. Tool bị tắt sẽ bị loại khỏi bộ công cụ của agent. "
"Connector MCP / REST-API được thiết lập ở tab con Connector."}, "Connector MCP / REST-API được thiết lập ở tab con Connector."},
"tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"}, "tools_admin.refresh": {"en": "Refresh", "ja": "更新", "vi": "Làm mới"},
# Mô tả tool HIỂN THỊ trên thẻ, một khoá cho mỗi ``TOOL_SPECS[].name``.
# KHÔNG dùng ``spec.description``: chuỗi đó là mô tả gửi cho mô hình trong
# schema function-calling, phải giữ nguyên tiếng Anh và viết cho máy đọc.
"tools_admin.desc.read_file": {
"en": "Read the contents of a text file in the working folder.",
"ja": "作業フォルダー内のテキストファイルの内容を読み取ります。",
"vi": "Đọc nội dung một tệp văn bản trong thư mục làm việc."},
"tools_admin.desc.list_dir": {
"en": "List files and subfolders at a path (defaults to the workdir root).",
"ja": "指定パスのファイルとサブフォルダーを一覧表示します(既定は作業フォルダー直下)。",
"vi": "Liệt kê tệp và thư mục con tại một đường dẫn (mặc định là gốc thư mục làm việc)."},
"tools_admin.desc.write_file": {
"en": "Create a new file or fully rewrite one. For small edits, prefer edit_file.",
"ja": "ファイルを新規作成、または全体を書き換えます。小さな修正には edit_file を使います。",
"vi": "Tạo tệp mới hoặc ghi đè toàn bộ. Sửa nhỏ thì nên dùng edit_file."},
"tools_admin.desc.edit_file": {
"en": "Replace an exact snippet inside an existing file — preferred for small edits.",
"ja": "既存ファイル内の特定の箇所を置き換えます。小さな修正に適しています。",
"vi": "Thay chính xác một đoạn trong tệp có sẵn — hợp cho các sửa đổi nhỏ."},
"tools_admin.desc.run_command": {
"en": "Run a shell command in the working folder and return its output.",
"ja": "作業フォルダーでシェルコマンドを実行し、その出力を返します。",
"vi": "Chạy một lệnh shell trong thư mục làm việc và trả về kết quả."},
"tools_admin.desc.install_package": {
"en": "Install a Python package (pip) so the task can use a missing library.",
"ja": "不足しているライブラリを使えるよう Python パッケージ(pip)をインストールします。",
"vi": "Cài gói Python (pip) để tác vụ dùng được thư viện còn thiếu."},
"tools_admin.desc.fetch_url": {
"en": "Fetch a web page or online document by URL and return its text.",
"ja": "URL から Web ページやオンライン文書を取得し、テキストを返します。",
"vi": "Tải trang web hoặc tài liệu trực tuyến theo URL và trả về nội dung văn bản."},
"tools_admin.desc.jira_search": {
"en": "Search Jira issues with a JQL query and return a summary list. Read-only.",
"ja": "JQL クエリで Jira の課題を検索し、一覧を返します。読み取り専用です。",
"vi": "Tìm issue Jira bằng truy vấn JQL và trả về danh sách tóm tắt. Chỉ đọc."},
"tools_admin.desc.jira_get_issue": {
"en": "Read one Jira issue's details by key, e.g. ABX-123.",
"ja": "キー(例: ABX-123)を指定して Jira 課題の詳細を読み取ります。",
"vi": "Đọc chi tiết một issue Jira theo mã, ví dụ ABX-123."},
"tools_admin.url_fetch_group": { "tools_admin.url_fetch_group": {
"en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)", "en": "Web access (fetch_url)", "ja": "Webアクセス (fetch_url)",
"vi": "Truy cập web (fetch_url)"}, "vi": "Truy cập web (fetch_url)"},
@@ -268,6 +307,12 @@ STRINGS: Dict[str, Dict[str, str]] = {
"co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"}, "co4e.custom": {"en": "custom", "ja": "カスタム", "vi": "tùy chỉnh"},
"co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"}, "co4e.parallel_node": {"en": "Parallel (fan-out)", "ja": "並列(ファンアウト)", "vi": "Song song (fan-out)"},
"co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"}, "co4e.add_step": {"en": "Add step", "ja": "ステップ追加", "vi": "Thêm bước"},
"co4e.canvas_add_next": {
"en": "Add next step", "ja": "次のステップを追加", "vi": "Thêm bước kế"},
"co4e.canvas_connect_from": {
"en": "Connect from here", "ja": "ここから接続", "vi": "Nối từ đây"},
"co4e.canvas_delete_edge": {
"en": "Delete connection", "ja": "接続を削除", "vi": "Xóa liên kết"},
"co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"}, "co4e.fit": {"en": "Fit", "ja": "全体表示", "vi": "Vừa màn hình"},
"co4e.fit_tooltip": { "co4e.fit_tooltip": {
"en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示", "en": "Auto-fit: zoom to show every step", "ja": "自動フィット:全ステップを表示",
+12 -3
View File
@@ -149,8 +149,14 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"}, "app.provider": {"en": "Provider:", "ja": "プロバイダー:", "vi": "Nhà cung cấp:"},
"app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"}, "app.language": {"en": "Language:", "ja": "言語:", "vi": "Ngôn ngữ:"},
"app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"}, "app.settings": {"en": "Settings", "ja": "設定", "vi": "Cài đặt"},
"app.tab.dashboard": {"en": "Dashboard", "ja": "Dashboard", "vi": "Dashboard"}, # Shown on the cover while a language switch blocks the GUI thread. It is
"app.tab.schedule": {"en": "Schedule Task", "ja": "Schedule Task", "vi": "Schedule Task"}, # deliberately read BEFORE the switch, so it appears in the language the
# user is leaving — the only one they can still read at that moment.
"app.lang.switching": {
"en": "Switching language…", "ja": "言語を切り替えています…",
"vi": "Đang đổi ngôn ngữ…"},
"app.tab.dashboard": {"en": "Dashboard", "ja": "ダッシュボード", "vi": "Dashboard"},
"app.tab.schedule": {"en": "Schedule Task", "ja": "タスクスケジュール", "vi": "Schedule Task"},
"app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"}, "app.tab.cowork": {"en": "Cowork", "ja": "Cowork", "vi": "Cowork"},
"app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"}, "app.tab.code": {"en": "Code", "ja": "Code", "vi": "Code"},
"app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"}, "app.tab.structure": {"en": "GraphRAG", "ja": "GraphRAG", "vi": "GraphRAG"},
@@ -158,7 +164,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"app.tab.monitoring": {"en": "Monitoring", "ja": "モニタリング", "vi": "Giám sát"}, "app.tab.monitoring": {"en": "Monitoring", "ja": "モニタリング", "vi": "Giám sát"},
"app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"}, "app.nav.collapse_tooltip": {"en": "Collapse menu to icons only", "ja": "メニューをアイコンのみに折りたたむ", "vi": "Thu gọn menu về icon"},
"app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"}, "app.nav.expand_tooltip": {"en": "Expand menu", "ja": "メニューを展開", "vi": "Mở rộng menu"},
"app.nav.menu_label": {"en": "MENU", "ja": "MENU", "vi": "MENU"}, "app.nav.menu_label": {"en": "MENU", "ja": "メニュー", "vi": "MENU"},
# Shown on the rail rows the project gate disables (Cowork, GraphRAG) — # Shown on the rail rows the project gate disables (Cowork, GraphRAG) —
# they stay listed and greyed instead of disappearing from the menu. # they stay listed and greyed instead of disappearing from the menu.
"app.nav.needs_project": { "app.nav.needs_project": {
@@ -171,6 +177,9 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Project cho đoạn chat mới"}, "vi": "Project cho đoạn chat mới"},
"app.nav.no_project": { "app.nav.no_project": {
"en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"}, "en": "No project yet", "ja": "プロジェクトなし", "vi": "Chưa có project"},
# KHAC no_project: đã có project, chỉ là người dùng chưa chọn cái nào.
"app.nav.pick_project": {
"en": "Select a project…", "ja": "プロジェクトを選択…", "vi": "Chọn project…"},
"app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"}, "app.nav.recents": {"en": "RECENTS", "ja": "最近", "vi": "GẦN ĐÂY"},
"app.nav.all_projects": { "app.nav.all_projects": {
"en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"}, "en": "All projects…", "ja": "すべてのプロジェクト…", "vi": "Tất cả project…"},
+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.ready": {"en": "Ready.", "ja": "準備完了。", "vi": "Sẵn sàng."},
"app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."}, "app.status.using_provider": {"en": "Using {label}.", "ja": "{label} を使用中。", "vi": "Đang dùng {label}."},
"app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."}, "app.status.settings_saved": {"en": "Settings saved.", "ja": "設定を保存しました。", "vi": "Đã lưu cài đặt."},
"app.credit": {"en": "Made by QuanDH14", "ja": "Made by QuanDH14", "vi": "Made by QuanDH14"}, # Con số lấy từ ``cowork_local.__version__`` — một nguồn duy nhất cho tiêu
# đề cửa sổ, tab Giới thiệu và góc dưới phải. Giữ nguyên dạng ở cả ba ngôn
# ngữ (đã khai vào KHOA_KHONG_CAN_DICH).
"app.version": {"en": "Version {v}", "ja": "Version {v}", "vi": "Version {v}"},
"app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"}, "app.tray.open": {"en": "Open Cowork Local", "ja": "Cowork Local を開く", "vi": "Mở Cowork Local"},
"app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"}, "app.tray.quit": {"en": "Quit", "ja": "終了", "vi": "Thoát"},
"app.tray.running_body": { "app.tray.running_body": {
@@ -197,6 +200,15 @@ STRINGS: Dict[str, Dict[str, str]] = {
"chat.provider_default_short": { "chat.provider_default_short": {
"en": "the provider's default model", "ja": "プロバイダー既定のモデル", "en": "the provider's default model", "ja": "プロバイダー既定のモデル",
"vi": "model mặc định của provider"}, "vi": "model mặc định của provider"},
"chat.provider_default_item": {
"en": "(provider default)", "ja": "(プロバイダー既定)",
"vi": "(mặc định của provider)"},
"chat.record_audio_start": {
"en": "Record Voice Note", "ja": "ボイスメモを録音", "vi": "Ghi âm ghi chú"},
"chat.record_audio_stop": {
"en": "Stop Recording", "ja": "録音を停止", "vi": "Dừng ghi âm"},
"chat.record_audio_cancel": {
"en": "Cancel recording", "ja": "録音をキャンセル", "vi": "Hủy ghi âm"},
"chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"}, "chat.delete_link": {"en": "Delete", "ja": "削除", "vi": "Xóa"},
"chat.delete_tooltip": { "chat.delete_tooltip": {
"en": "Delete this message and its input/output files", "en": "Delete this message and its input/output files",
+2 -2
View File
@@ -123,7 +123,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"vi": "Không phân tích được template này. Kiểm tra file .pptx/.xlsx hợp lệ và provider AI trong Settings hoạt động tốt, hoặc tự thêm skill thủ công."}, "vi": "Không phân tích được template này. Kiểm tra file .pptx/.xlsx hợp lệ và provider AI trong Settings hoạt động tốt, hoặc tự thêm skill thủ công."},
# ---- flow_dialog.py ----------------------------------------------- # ---- flow_dialog.py -----------------------------------------------
"flow.title": {"en": "Flow Management", "ja": "Flow Management", "vi": "Flow Management"}, "flow.title": {"en": "Flow Management", "ja": "フロー管理", "vi": "Flow Management"},
"flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"}, "flow.tab_flow": {"en": "Flow", "ja": "フロー", "vi": "Flow"},
"flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"}, "flow.tab_agents": {"en": "Agents", "ja": "エージェント", "vi": "Agents"},
"flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"}, "flow.tab_skills": {"en": "Skills", "ja": "スキル", "vi": "Skills"},
@@ -140,7 +140,7 @@ STRINGS: Dict[str, Dict[str, str]] = {
"flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"}, "flow.task_prompt": {"en": "Task (prompt)", "ja": "タスク(プロンプト)", "vi": "Nhiệm vụ (prompt)"},
"flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"}, "flow.skill": {"en": "Skill", "ja": "スキル", "vi": "Skill"},
"flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"}, "flow.agent": {"en": "AI provider", "ja": "AI プロバイダー", "vi": "AI provider"},
"flow.model_label": {"en": "Agent:", "ja": "Agent:", "vi": "Agent:"}, "flow.model_label": {"en": "Agent:", "ja": "エージェント:", "vi": "Agent:"},
"flow.default_model": {"en": "(provider default)", "ja": "(プロバイダー既定)", "vi": "(mặc định của provider)"}, "flow.default_model": {"en": "(provider default)", "ja": "(プロバイダー既定)", "vi": "(mặc định của provider)"},
"flow.gen_task_from_hint": {"en": "Generate task from hint", "ja": "ヒントからタスクを生成", "vi": "Tạo task từ gợi ý"}, "flow.gen_task_from_hint": {"en": "Generate task from hint", "ja": "ヒントからタスクを生成", "vi": "Tạo task từ gợi ý"},
"flow.gen_task_tooltip": { "flow.gen_task_tooltip": {
+27 -1
View File
@@ -62,7 +62,9 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
""" """
from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env from cowork_local.core.deps import network_blocked_env, run_cancellable, sandbox_env
from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager from cowork_local.core.sandbox_manager import ExecutionConfig, SandboxManager
from cowork_local.security.command_risk_classifier import classify_command from cowork_local.security.command_risk_classifier import (
classify_command, command_bypasses_network_proxy,
)
command = str(args.get("command", "")).strip() command = str(args.get("command", "")).strip()
if not command: if not command:
@@ -74,6 +76,21 @@ def run_command(ctx: ToolContext, args: Dict[str, Any],
denial = "Command blocked by security policy: " + "; ".join(risk.reasons) denial = "Command blocked by security policy: " + "; ".join(risk.reasons)
return {"ok": False, "output": denial} return {"ok": False, "output": denial}
# 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 # Route through SandboxManager for risk-based isolation
mgr = SandboxManager(ExecutionConfig( mgr = SandboxManager(ExecutionConfig(
enabled=True, enabled=True,
@@ -111,6 +128,15 @@ def install_package(ctx: ToolContext, args: Dict[str, Any],
package = str(args.get("package", "")).strip() package = str(args.get("package", "")).strip()
if not package: if not package:
return {"ok": False, "output": "No package specified."} return {"ok": False, "output": "No package specified."}
# ``pip install`` bắt buộc phải ra internet, mà ``deps.pip_install`` chạy
# subprocess với ``os.environ`` nguyên vẹn — biến proxy hố đen của
# ``network_blocked_env`` không chạm tới nó. Từ chối thẳng ở đây (giống cách
# run_command chặn theo tên các công cụ không đi qua proxy) thay vì để pip
# thử 600 giây rồi báo một lỗi proxy khó hiểu.
if ctx.block_network:
return {"ok": False, "output": (
"install_package: network access is blocked by the Sandbox Security Layer "
"(\"Block network for agent-run commands\" is on in Settings).")}
python = _sandbox_python(ctx, cancel, on_output) python = _sandbox_python(ctx, cancel, on_output)
ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python) ok, detail = pip_install(package, cancel=cancel, on_output=on_output, python=python)
head = f"Installed {package}." if ok else f"Could not install {package}." head = f"Installed {package}." if ok else f"Could not install {package}."
+25 -1
View File
@@ -6,11 +6,26 @@ tag added in R05-T01/domain/tools/tool_registry.py describes.
""" """
from __future__ import annotations from __future__ import annotations
from typing import Any, Dict from typing import Any, Dict, Optional
from .tool_context import ToolContext from .tool_context import ToolContext
def _network_refusal(ctx: ToolContext, tool: str) -> Optional[Dict[str, Any]]:
"""Lời từ chối khi Sandbox Security Layer đang chặn mạng; None nếu được đi.
``block_network`` trước đây chỉ được đọc ở ``command_tools.py`` (lệnh shell),
nên ba tool mang ``ToolCapability.NETWORK`` ở file này vẫn ra internet bình
thường trong khi Monitoring báo "Mạng: Bị chặn". Kiểm ở đây, TRƯỚC mọi lời
gọi mạng, để công tắc chặn đúng thứ nó nói là chặn.
"""
if not ctx.block_network:
return None
return {"ok": False, "output": (
f"{tool}: network access is blocked by the Sandbox Security Layer "
"(\"Block network for agent-run commands\" is on in Settings).")}
def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Fetch a URL's text content (web page / online document / SharePoint- """Fetch a URL's text content (web page / online document / SharePoint-
OneDrive share link) via link_fetch — the same parser task-link attachments OneDrive share link) via link_fetch — the same parser task-link attachments
@@ -20,6 +35,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
return {"ok": False, "output": "fetch_url: 'url' is required."} return {"ok": False, "output": "fetch_url: 'url' is required."}
if not url.lower().startswith(("http://", "https://")): if not url.lower().startswith(("http://", "https://")):
return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"} return {"ok": False, "output": f"fetch_url: not an http(s) URL: {url}"}
blocked = _network_refusal(ctx, "fetch_url")
if blocked is not None:
return blocked
if not ctx.allow_url_fetch: if not ctx.allow_url_fetch:
return {"ok": False, return {"ok": False,
"output": ("fetch_url: URL fetching is turned off in Settings → Security " "output": ("fetch_url: URL fetching is turned off in Settings → Security "
@@ -37,6 +55,9 @@ def fetch_url(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Tìm issue trên Jira bằng JQL.""" """Tìm issue trên Jira bằng JQL."""
blocked = _network_refusal(ctx, "jira_search")
if blocked is not None:
return blocked
from cowork_local.core import jira_tool from cowork_local.core import jira_tool
out = jira_tool.search(ctx.jira, str(args.get("jql", "")), out = jira_tool.search(ctx.jira, str(args.get("jql", "")),
@@ -47,6 +68,9 @@ def jira_search(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]: def jira_get_issue(ctx: ToolContext, args: Dict[str, Any]) -> Dict[str, Any]:
"""Lấy chi tiết một issue Jira theo mã.""" """Lấy chi tiết một issue Jira theo mã."""
blocked = _network_refusal(ctx, "jira_get_issue")
if blocked is not None:
return blocked
from cowork_local.core import jira_tool from cowork_local.core import jira_tool
out = jira_tool.get_issue(ctx.jira, str(args.get("key", ""))) out = jira_tool.get_issue(ctx.jira, str(args.get("key", "")))
+8 -4
View File
@@ -37,12 +37,16 @@ class ToolContext:
# None (default) = no limits, matching pre-existing behavior. # None (default) = no limits, matching pre-existing behavior.
resource_limits: Optional[Dict[str, float]] = None resource_limits: Optional[Dict[str, float]] = None
# Sandbox Security Layer — Settings' "Block network for agent commands" # Sandbox Security Layer — Settings' "Block network for agent commands"
# (policy-level, see deps.py::network_blocked_env). False (default) = # — the proxy-env block for shell commands (deps.py::network_blocked_env)
# AND a flat refusal from every NETWORK-capability tool, which reaches the
# net in-process where proxy env vars mean nothing. False (default) =
# unrestricted, matching pre-existing behavior. # unrestricted, matching pre-existing behavior.
block_network: bool = False block_network: bool = False
# Whether the fetch_url tool may read URLs — SEPARATE from block_network # Whether the fetch_url tool may read URLs. Its own toggle, but NOT a way
# (reading a web page/share link for info is safe; running networked shell # around block_network: with the network blocked every NETWORK-capability
# commands is the risk). Defaults True; set from agent_security.allow_url_fetch. # tool is refused first (fetch_tools.py::_network_refusal), so this flag only
# decides anything while the network is open. Defaults True; set from
# agent_security.allow_url_fetch.
allow_url_fetch: bool = True allow_url_fetch: bool = True
# Jira read connector config (base_url/email/api_token) — None disables the # Jira read connector config (base_url/email/api_token) — None disables the
# jira_* tools' ability to connect. Populated from config.data["jira"]. # jira_* tools' ability to connect. Populated from config.data["jira"].
+10 -5
View File
@@ -17,7 +17,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from cowork_local.i18n import tr from cowork_local.i18n import bind_dynamic, bind_tip, tr
from cowork_local.theme import current_palette from cowork_local.theme import current_palette
from cowork_local.ui.icons import icon from cowork_local.ui.icons import icon
@@ -57,7 +57,7 @@ class AudioRecorderWidget(QWidget):
# Record / Stop toggle button # Record / Stop toggle button
self.record_btn = QPushButton() self.record_btn = QPushButton()
self.record_btn.setIcon(icon("microphone")) self.record_btn.setIcon(icon("microphone"))
self.record_btn.setToolTip(tr("chat.record_audio_start") if tr("chat.record_audio_start") != "chat.record_audio_start" else "Record Voice Note") bind_dynamic(self.record_btn, self._sync_record_tip)
self.record_btn.setFixedSize(32, 32) self.record_btn.setFixedSize(32, 32)
self.record_btn.clicked.connect(self.toggle_recording) self.record_btn.clicked.connect(self.toggle_recording)
layout.addWidget(self.record_btn) layout.addWidget(self.record_btn)
@@ -78,7 +78,7 @@ class AudioRecorderWidget(QWidget):
self.cancel_btn = QPushButton() self.cancel_btn = QPushButton()
self.cancel_btn.setIcon(icon("x")) self.cancel_btn.setIcon(icon("x"))
self.cancel_btn.setToolTip("Cancel recording") bind_tip(self.cancel_btn, "chat.record_audio_cancel")
self.cancel_btn.setFixedSize(24, 24) self.cancel_btn.setFixedSize(24, 24)
self.cancel_btn.clicked.connect(self.cancel_recording) self.cancel_btn.clicked.connect(self.cancel_recording)
status_layout.addWidget(self.cancel_btn) status_layout.addWidget(self.cancel_btn)
@@ -106,7 +106,7 @@ class AudioRecorderWidget(QWidget):
self.timer_label.setText("00:00") self.timer_label.setText("00:00")
self.status_container.setVisible(True) self.status_container.setVisible(True)
self.record_btn.setIcon(icon("square")) self.record_btn.setIcon(icon("square"))
self.record_btn.setToolTip("Stop Recording") self._sync_record_tip()
self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;") self.record_btn.setStyleSheet("background-color: #fca5a5; color: #991b1b;")
self._timer.start() self._timer.start()
self.recording_started.emit() self.recording_started.emit()
@@ -137,7 +137,12 @@ class AudioRecorderWidget(QWidget):
self.status_container.setVisible(False) self.status_container.setVisible(False)
self.record_btn.setIcon(icon("microphone")) self.record_btn.setIcon(icon("microphone"))
self.record_btn.setStyleSheet("") self.record_btn.setStyleSheet("")
self.record_btn.setToolTip("Record Voice Note") self._sync_record_tip()
def _sync_record_tip(self) -> None:
"""Tooltip nút ghi âm nói việc nó sẽ làm tiếp, theo trạng thái hiện tại."""
self.record_btn.setToolTip(tr("chat.record_audio_stop" if self._is_recording
else "chat.record_audio_start"))
def _on_tick(self) -> None: def _on_tick(self) -> None:
"""Update recording duration display every second.""" """Update recording duration display every second."""
+1 -1
View File
@@ -141,7 +141,7 @@ class ChatAgentsMixin:
if not items and self.agent_combo.count() == 0: if not items and self.agent_combo.count() == 0:
# No models found and none configured — placeholder with data=None so # No models found and none configured — placeholder with data=None so
# we fall back to the provider's default model (never a fake name). # we fall back to the provider's default model (never a fake name).
self.agent_combo.addItem("(provider default)", None) self.agent_combo.addItem(tr("chat.provider_default_item"), None)
keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}" keep_data = (f"{self._ADMIN_AGENT_PREFIX}{self._admin_agent.agent_id}"
if getattr(self, "_admin_agent", None) is not None else keep) if getattr(self, "_admin_agent", None) is not None else keep)
idx = self.agent_combo.findData(keep_data) if keep_data else -1 idx = self.agent_combo.findData(keep_data) if keep_data else -1
+2 -2
View File
@@ -11,9 +11,9 @@ from __future__ import annotations
from pathlib import Path from pathlib import Path
from typing import Any, Dict, List, Optional from typing import Any, Dict, List, Optional
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QMessageBox
from ...core.worker import AgentWorker from ...core.worker import AgentWorker
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import confirm
class ChatSessionMixin: class ChatSessionMixin:
@@ -308,7 +308,7 @@ class ChatSessionMixin:
prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview) prompt = tr("chatpanel.delete_confirm_files", n=len(files), preview=preview)
else: else:
prompt = tr("chatpanel.delete_confirm_plain") prompt = tr("chatpanel.delete_confirm_plain")
if QMessageBox.question(self, tr("chatpanel.delete_confirm_title"), prompt) != QMessageBox.Yes: if not confirm(self, tr("chatpanel.delete_confirm_title"), prompt):
return return
for bubble in turn.get("bubbles", []): for bubble in turn.get("bubbles", []):
bubble.setParent(None) bubble.setParent(None)
+7 -5
View File
@@ -35,7 +35,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget from PySide6.QtWidgets import QHBoxLayout, QPushButton, QVBoxLayout, QWidget
from ...i18n import tr from ...i18n import bind_text, bind_tip
from ...ui.icons import icon from ...ui.icons import icon
from .palette_list import _PaletteList from .palette_list import _PaletteList
@@ -55,9 +55,11 @@ class AgentListPanel(QWidget):
def __init__(self, parent: QWidget | None = None) -> None: def __init__(self, parent: QWidget | None = None) -> None:
"""Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới.""" """Danh sách agent ở cột trái Co4E Studio, kèm nút tạo mới."""
super().__init__(parent) super().__init__(parent)
self.new_btn = QPushButton(tr("co4e.new")) # Bound, not set once: this panel has no retranslate hook of its own, and
# Co4ETab (which owns the language callback) cannot reach these tooltips.
self.new_btn = bind_text(QPushButton(), "co4e.new")
self.new_btn.setIcon(icon("plus")) self.new_btn.setIcon(icon("plus"))
self.new_btn.setToolTip(tr("co4e.tt_new_agent")) bind_tip(self.new_btn, "co4e.tt_new_agent")
self.new_btn.setObjectName("co4eSectionAction") self.new_btn.setObjectName("co4eSectionAction")
self.new_btn.setFlat(True) self.new_btn.setFlat(True)
self.new_btn.setCursor(Qt.PointingHandCursor) self.new_btn.setCursor(Qt.PointingHandCursor)
@@ -75,11 +77,11 @@ class AgentListPanel(QWidget):
# Edit/delete act on the selected row, so they stay with the list. # Edit/delete act on the selected row, so they stay with the list.
self.edit_btn = QPushButton() self.edit_btn = QPushButton()
self.edit_btn.setIcon(icon("edit")) self.edit_btn.setIcon(icon("edit"))
self.edit_btn.setToolTip(tr("co4e.tt_edit_agent")) bind_tip(self.edit_btn, "co4e.tt_edit_agent")
self.edit_btn.setFixedWidth(34) self.edit_btn.setFixedWidth(34)
self.del_btn = QPushButton() self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash")) self.del_btn.setIcon(icon("trash"))
self.del_btn.setToolTip(tr("co4e.tt_del_agent")) bind_tip(self.del_btn, "co4e.tt_del_agent")
self.del_btn.setFixedWidth(34) self.del_btn.setFixedWidth(34)
# KHONG noi .clicked o day: cung ly do nhu new_btn o tren. # KHONG noi .clicked o day: cung ly do nhu new_btn o tren.
btns.addWidget(self.edit_btn) btns.addWidget(self.edit_btn)
+13 -4
View File
@@ -34,6 +34,7 @@ from PySide6.QtGui import QBrush, QColor, QPainterPath, QPen, QPolygonF
from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu from PySide6.QtWidgets import QGraphicsItem, QGraphicsObject, QGraphicsPathItem, QMenu
from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node from ...core.co4e import STEP_DONE, STEP_ERROR, STEP_PLANNED, STEP_RUNNING, Edge, Node
from ...i18n import tr
from ...theme import current_palette from ...theme import current_palette
from .canvas_geometry import _elide, _rounded_path from .canvas_geometry import _elide, _rounded_path
@@ -199,6 +200,14 @@ class _NodeItem(QGraphicsObject):
e.accept() e.accept()
return return
super().mousePressEvent(e) super().mousePressEvent(e)
if self.isSelected():
# itemChange() only emits node_selected when the SELECTION STATE
# actually flips (ItemSelectedHasChanged) — clicking a node that
# was already selected (e.g. left selected when a run started)
# never re-fires it, so the property panel silently kept showing
# stale data and looked "locked" while the node ran. Emit
# explicitly on every click so the panel always reloads.
self.canvas.node_selected.emit(self.node.id)
def mouseMoveEvent(self, e): def mouseMoveEvent(self, e):
"""Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ.""" """Rê chuột trong lúc kéo nối: vẽ lại đường nét đứt theo con trỏ."""
@@ -225,9 +234,9 @@ class _NodeItem(QGraphicsObject):
def contextMenuEvent(self, e): def contextMenuEvent(self, e):
"""Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước.""" """Menu chuột phải trên node: thêm bước kế, nối từ đây, xoá bước."""
menu = QMenu() menu = QMenu()
a_add = menu.addAction("+ Add next step") a_add = menu.addAction("+ " + tr("co4e.canvas_add_next"))
a_conn = menu.addAction("→ Connect from here") a_conn = menu.addAction("→ " + tr("co4e.canvas_connect_from"))
a_del = menu.addAction("🗑 Delete step") a_del = menu.addAction("🗑 " + tr("co4e.delete_step"))
chosen = menu.exec(e.screenPos()) chosen = menu.exec(e.screenPos())
if chosen is a_add: if chosen is a_add:
self.canvas.add_step_below(self.node.id) self.canvas.add_step_below(self.node.id)
@@ -334,7 +343,7 @@ class _EdgeItem(QGraphicsPathItem):
def contextMenuEvent(self, e): def contextMenuEvent(self, e):
"""Menu chuột phải trên đường nối: xoá liên kết.""" """Menu chuột phải trên đường nối: xoá liên kết."""
menu = QMenu() menu = QMenu()
act_del = menu.addAction("🗑 Delete connection") act_del = menu.addAction("🗑 " + tr("co4e.canvas_delete_edge"))
if menu.exec(e.screenPos()) is act_del: if menu.exec(e.screenPos()) is act_del:
self.canvas.delete_edge(self.edge) self.canvas.delete_edge(self.edge)
e.accept() e.accept()
+9
View File
@@ -273,6 +273,15 @@ class Co4ECanvas(_CanvasInteractionMixin, QGraphicsView):
item.status = status item.status = status
item.update() item.update()
def node_status(self, node_id: str) -> str:
"""Trạng thái chạy hiện tại của một node — "idle" nếu không tìm thấy.
Dùng để quyết định có khóa bảng thuộc tính bên phải hay không khi
người dùng chọn node (xem ``StepConfigPanel.set_locked``).
"""
item = self._nodes.get(node_id)
return item.status if item is not None else "idle"
def reset_statuses(self) -> None: def reset_statuses(self) -> None:
"""Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng.""" """Đưa mọi node về trạng thái chờ — gọi trước mỗi lần chạy lại luồng."""
for it in self._nodes.values(): for it in self._nodes.values():
+7 -1
View File
@@ -13,7 +13,7 @@ from PySide6.QtWidgets import QSplitter, QWidget
from ...core import co4e, skills as skills_mod from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS from ...core.co4e_builtins import BUILTIN_AGENTS
from ...core.worker import AgentWorker from ...core.worker import AgentWorker
from ...i18n import tr from ...i18n import bind_dynamic, tr
from ...ui.chat_view import ChatView from ...ui.chat_view import ChatView
from ...ui.icons import icon from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import ChatPanel from ...presentation.co4e.co4e_chat_view import ChatPanel
@@ -55,6 +55,12 @@ class Co4EChatMixin:
self._co4e_routed_provider = None # routing provider override for the next turn self._co4e_routed_provider = None # routing provider override for the next turn
self._vsplit_sizes = [540, 220] # sizes to restore when expanded self._vsplit_sizes = [540, 220] # sizes to restore when expanded
self._msgs_collapsed = True self._msgs_collapsed = True
# The tooltip names the action the button would perform, so it depends on
# which way the box is folded — and the fold state lives here, not in the
# panel. Bound so a language change re-reads it instead of freezing the
# wording set when the tab was built.
bind_dynamic(self.chat_toggle_btn, lambda: self.chat_toggle_btn.setToolTip(
tr("co4e.tt_expand_msgs" if self._msgs_collapsed else "co4e.tt_collapse_msgs")))
return panel return panel
def _toggle_messages(self) -> None: def _toggle_messages(self) -> None:
"""Show/hide the WHOLE chat box (message list + composer) below the """Show/hide the WHOLE chat box (message list + composer) below the
+6 -4
View File
@@ -48,7 +48,7 @@ from PySide6.QtWidgets import (
from ...core import co4e, skills as skills_mod from ...core import co4e, skills as skills_mod
from ...core.co4e_builtins import BUILTIN_AGENTS from ...core.co4e_builtins import BUILTIN_AGENTS
from ...i18n import tr from ...i18n import bind_placeholder, bind_text, tr
from ...theme import current_palette from ...theme import current_palette
from ...ui.icons import icon from ...ui.icons import icon
from ...ui.routing_toggle import RoutingToggle from ...ui.routing_toggle import RoutingToggle
@@ -223,7 +223,8 @@ class ChatPanel(QWidget):
self.header = QWidget(); self.header.setObjectName("msgHeader") self.header = QWidget(); self.header.setObjectName("msgHeader")
mh = QHBoxLayout(self.header); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6) mh = QHBoxLayout(self.header); mh.setContentsMargins(6, 3, 6, 3); mh.setSpacing(6)
self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14)) self.msgs_icon = QLabel(); self.msgs_icon.setPixmap(icon("message").pixmap(14, 14))
self.msgs_title = QLabel(tr("co4e.messages")); self.msgs_title.setObjectName("hint") self.msgs_title = bind_text(QLabel(), "co4e.messages")
self.msgs_title.setObjectName("hint")
self.chat_toggle_btn = QPushButton() self.chat_toggle_btn = QPushButton()
self.chat_toggle_btn.setObjectName("msgToggle") self.chat_toggle_btn.setObjectName("msgToggle")
self.chat_toggle_btn.setFlat(True) self.chat_toggle_btn.setFlat(True)
@@ -253,10 +254,11 @@ class ChatPanel(QWidget):
crow.addWidget(self.usage_total_lbl) crow.addWidget(self.usage_total_lbl)
_inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0) _inp = QWidget(); row = QHBoxLayout(_inp); row.setContentsMargins(0, 0, 0, 0)
self.chat_input = _ChatInput() self.chat_input = _ChatInput()
self.chat_input.setPlaceholderText(tr("co4e.chat_placeholder")) bind_placeholder(self.chat_input, "co4e.chat_placeholder")
# KHONG noi .submit o day: cung ly do nhu chat_toggle_btn o tren # KHONG noi .submit o day: cung ly do nhu chat_toggle_btn o tren
# (ben goi noi toi _chat_send cua chinh no). # (ben goi noi toi _chat_send cua chinh no).
self.chat_send_btn = QPushButton(tr("co4e.send")); self.chat_send_btn.setIcon(icon("send")) self.chat_send_btn = bind_text(QPushButton(), "co4e.send")
self.chat_send_btn.setIcon(icon("send"))
# KHONG noi .clicked o day: cung ly do nhu tren. # KHONG noi .clicked o day: cung ly do nhu tren.
row.addWidget(self.chat_input, 1) row.addWidget(self.chat_input, 1)
# Off/Auto/Manual routing toggle for Co4E (surface key "co4e"). # Off/Auto/Manual routing toggle for Co4E (surface key "co4e").
+5
View File
@@ -101,6 +101,11 @@ class Co4EFlowTabsMixin:
self.center_stack.setCurrentIndex(1) self.center_stack.setCurrentIndex(1)
self._sync_runs_toggle(False) self._sync_runs_toggle(False)
self._apply_workflow(self._flows[flow_idx]) self._apply_workflow(self._flows[flow_idx])
# _apply_workflow() rebuilds the canvas from wf.nodes/edges, which
# resets every node's live status to "idle" — without this, coming
# back to a flow that's still running (e.g. from the Runs page)
# shows every node as idle even though it's actually mid-run.
self._reflect_active_run(self._flows[flow_idx].id)
def _sync_runs_toggle(self, on: bool) -> None: def _sync_runs_toggle(self, on: bool) -> None:
"""Keep the Runs toggle showing which page is up, however it got there """Keep the Runs toggle showing which page is up, however it got there
(a double-click in the runs table also switches pages).""" (a double-click in the runs table also switches pages)."""
+4 -2
View File
@@ -14,7 +14,7 @@ from typing import List
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget from PySide6.QtWidgets import QComboBox, QFrame, QHBoxLayout, QLabel, QLineEdit, QPushButton, QScrollArea, QSizePolicy, QSpacerItem, QSplitter, QTabBar, QTabWidget, QVBoxLayout, QWidget
from ...core import co4e from ...core import co4e
from ...i18n import tr from ...i18n import bind_text, tr
from ...theme import current_palette from ...theme import current_palette
from ...ui.co4e_canvas import Co4ECanvas from ...ui.co4e_canvas import Co4ECanvas
from ...ui.icons import icon from ...ui.icons import icon
@@ -141,7 +141,9 @@ class Co4ELayoutMixin:
self.runs_btn.setToolTip(tr("co4e.tt_runs_tab")) self.runs_btn.setToolTip(tr("co4e.tt_runs_tab"))
self.runs_btn.toggled.connect(self._show_runs) self.runs_btn.toggled.connect(self._show_runs)
bar.addWidget(QLabel(tr("co4e.flow_name"))) # Bound: nothing else holds this label, so a one-shot tr() here would
# leave "Flow" stuck in the language the toolbar was built in.
bar.addWidget(bind_text(QLabel(), "co4e.flow_name"))
bar.addWidget(self.name_edit, 1) bar.addWidget(self.name_edit, 1)
bar.addWidget(self.add_step_btn) bar.addWidget(self.add_step_btn)
bar.addWidget(self.save_btn) bar.addWidget(self.save_btn)
+15 -13
View File
@@ -39,7 +39,7 @@ from PySide6.QtWidgets import (
QWidget, QWidget,
) )
from ...i18n import tr from ...i18n import bind_text, bind_tip
from ...ui.icons import icon from ...ui.icons import icon
@@ -66,13 +66,15 @@ class RunsPagePanel(QWidget):
hdr = QHBoxLayout() hdr = QHBoxLayout()
# The Runs page covers the flow toolbar, so it carries its own way back — # The Runs page covers the flow toolbar, so it carries its own way back —
# otherwise the toggle that opened it is off screen. # otherwise the toggle that opened it is off screen.
self.back_btn = QPushButton(tr("co4e.back_to_flow")) # Bound, not set once: this panel has no retranslate hook of its own, and
# Co4ETab (which owns the language callback) cannot reach these strings.
self.back_btn = bind_text(QPushButton(), "co4e.back_to_flow")
self.back_btn.setIcon(icon("chevron-left")) self.back_btn.setIcon(icon("chevron-left"))
self.back_btn.setToolTip(tr("co4e.tt_back_to_flow")) bind_tip(self.back_btn, "co4e.tt_back_to_flow")
# KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao # KHONG noi .clicked o day: ben goi (Co4ETab) tu quyet dinh slot nao
# xu ly - panel chi dung widget, khong biet _show_runs la gi. # xu ly - panel chi dung widget, khong biet _show_runs la gi.
hdr.addWidget(self.back_btn) hdr.addWidget(self.back_btn)
self.title_label = QLabel(tr("co4e.running_flows")) self.title_label = bind_text(QLabel(), "co4e.running_flows")
self.title_label.setObjectName("hint") self.title_label.setObjectName("hint")
hdr.addWidget(self.title_label) hdr.addWidget(self.title_label)
# Show + open the workspace folder where flow outputs land (below the tab, # Show + open the workspace folder where flow outputs land (below the tab,
@@ -85,21 +87,21 @@ class RunsPagePanel(QWidget):
# ca hai deu thuoc Co4ETab (can ctx/manager de biet duong dan that). # ca hai deu thuoc Co4ETab (can ctx/manager de biet duong dan that).
hdr.addWidget(self.ws_folder_btn) hdr.addWidget(self.ws_folder_btn)
hdr.addStretch(1) hdr.addStretch(1)
self.stop_btn = QPushButton(tr("co4e.stop")) self.stop_btn = bind_text(QPushButton(), "co4e.stop")
self.stop_btn.setIcon(icon("stop")) self.stop_btn.setIcon(icon("stop"))
self.stop_btn.setObjectName("danger") self.stop_btn.setObjectName("danger")
self.stop_btn.setToolTip(tr("co4e.tt_stop_run")) bind_tip(self.stop_btn, "co4e.tt_stop_run")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. # KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
self.rename_btn = QPushButton(tr("co4e.rename_run")) self.rename_btn = bind_text(QPushButton(), "co4e.rename_run")
self.rename_btn.setIcon(icon("edit")) self.rename_btn.setIcon(icon("edit"))
self.rename_btn.setToolTip(tr("co4e.tt_rename_run")) bind_tip(self.rename_btn, "co4e.tt_rename_run")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. # KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
self.del_btn = QPushButton(tr("co4e.delete_run")) self.del_btn = bind_text(QPushButton(), "co4e.delete_run")
self.del_btn.setIcon(icon("trash")) self.del_btn.setIcon(icon("trash"))
self.del_btn.setToolTip(tr("co4e.tt_delete_run")) bind_tip(self.del_btn, "co4e.tt_delete_run")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. # KHONG noi .clicked o day: cung ly do nhu back_btn o tren.
self.clear_btn = QPushButton(tr("co4e.clear_done")) self.clear_btn = bind_text(QPushButton(), "co4e.clear_done")
self.clear_btn.setToolTip(tr("co4e.tt_clear_runs")) bind_tip(self.clear_btn, "co4e.tt_clear_runs")
# KHONG noi .clicked o day: cung ly do nhu back_btn o tren. (Ban goc # KHONG noi .clicked o day: cung ly do nhu back_btn o tren. (Ban goc
# noi thang toi lambda: self.manager.clear_finished(), khong qua mot # noi thang toi lambda: self.manager.clear_finished(), khong qua mot
# method rieng - Co4ETab van giu dung quirk do khi noi lai signal nay.) # method rieng - Co4ETab van giu dung quirk do khi noi lai signal nay.)
@@ -113,7 +115,7 @@ class RunsPagePanel(QWidget):
self.table.verticalHeader().setVisible(False) self.table.verticalHeader().setVisible(False)
self.table.setEditTriggers(QTableWidget.NoEditTriggers) self.table.setEditTriggers(QTableWidget.NoEditTriggers)
self.table.setSelectionBehavior(QTableWidget.SelectRows) self.table.setSelectionBehavior(QTableWidget.SelectRows)
self.table.setToolTip(tr("co4e.tt_runs_list")) bind_tip(self.table, "co4e.tt_runs_list")
# KHONG noi .itemDoubleClicked o day: cung ly do nhu back_btn o tren. # KHONG noi .itemDoubleClicked o day: cung ly do nhu back_btn o tren.
# Right-click a run → Open / Delete (delete a single old run from history). # Right-click a run → Open / Delete (delete a single old run from history).
self.table.setContextMenuPolicy(Qt.CustomContextMenu) self.table.setContextMenuPolicy(Qt.CustomContextMenu)
+13 -5
View File
@@ -13,10 +13,11 @@ import re
from pathlib import Path from pathlib import Path
from typing import Dict, List, Optional from typing import Dict, List, Optional
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu, QMessageBox, QTableWidget, QTableWidgetItem from PySide6.QtWidgets import QMenu, QMessageBox, QTableWidget, QTableWidgetItem
from ...core import co4e from ...core import co4e
from ...i18n import tr from ...i18n import tr
from ...theme import current_palette from ...theme import current_palette
from .co4e_workflow_crud import _LOCKED_NODE_STATUSES
class Co4ERunsMixin: class Co4ERunsMixin:
@@ -155,7 +156,14 @@ class Co4ERunsMixin:
t = ev.get("type") t = ev.get("type")
if t == "node_status": if t == "node_status":
if shown: if shown:
self.canvas.update_node_status(ev.get("node_id"), ev.get("status")) nid = ev.get("node_id")
self.canvas.update_node_status(nid, ev.get("status"))
# If the panel is showing THIS node right now (e.g. it was
# idle and the user had it open when the run started), keep
# the lock in sync instead of waiting for the next click.
if nid == getattr(self.config, "_node_id", None):
self.config.set_locked(
self.canvas.node_status(nid) in _LOCKED_NODE_STATUSES)
elif t == "node_output": elif t == "node_output":
if run_wf is not None: if run_wf is not None:
self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "") self._outputs_for(run_wf)[ev["node_id"]] = ev.get("output", "")
@@ -329,9 +337,9 @@ class Co4ERunsMixin:
h = self.manager.get(run_id) h = self.manager.get(run_id)
if h is None: if h is None:
return return
from PySide6.QtWidgets import QInputDialog from ...ui.dialog_buttons import ask_text
new, ok = QInputDialog.getText(self, tr("co4e.rename_run"), new, ok = ask_text(self, tr("co4e.rename_run"),
tr("co4e.rename_run_label"), text=h.name) tr("co4e.rename_run_label"), text=h.name)
new = (new or "").strip() new = (new or "").strip()
if not ok or not new or new == h.name: if not ok or not new or new == h.name:
return return
+53 -15
View File
@@ -11,14 +11,42 @@ from typing import List
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget from PySide6.QtWidgets import QHBoxLayout, QListWidget, QListWidgetItem, QPushButton, QSplitter, QVBoxLayout, QWidget
from ...core import co4e, skills as skills_mod from ...core import co4e, skills as skills_mod
from ...i18n import tr from ...i18n import bind_dynamic, bind_text, bind_tip, tr
from ...ui.icons import icon from ...ui.icons import icon
from ...presentation.co4e.agent_list_panel import AgentListPanel from ...presentation.co4e.agent_list_panel import AgentListPanel
from ...presentation.co4e.co4e_chat_view import _skill_names
from ...presentation.co4e.palette_list import _PaletteList from ...presentation.co4e.palette_list import _PaletteList
from ...presentation.co4e.skills_list_panel import SkillsListPanel from ...presentation.co4e.skills_list_panel import SkillsListPanel
def _skill_prefix_lookup(all_skills):
"""Answer ``skills.skill_prefix_for`` from an ALREADY-LOADED skill list.
``skill_prefix_for`` re-reads the whole skill folder on every call, so
asking it once per skill made a sidebar reload cost one full disk scan per
skill — measured at ~3.8s of frozen GUI thread on a 121-skill library, and
that reload runs on every language switch.
The scan order and the blank-instructions rule are copied from
``skill_prefix_for`` deliberately: a namesake with no instructions must NOT
end the search, or a skill's text silently becomes empty in an agent prompt.
"""
cache: dict = {}
def lookup(name: str) -> str:
"""The ``## Skill: <name>\\n<instructions>`` block for one name, or ''."""
if not name:
return ""
low = name.strip().lower()
if low not in cache:
cache[low] = next(
(f"## Skill: {s.name}\n{s.instructions.strip()}" for s in all_skills
if (s.slug == low or s.name.lower() == low) and s.instructions.strip()),
"")
return cache[low]
return lookup
class Co4ESidebarMixin: class Co4ESidebarMixin:
"""Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status.""" """Cột trái của Co4E Studio: Workflows, Agents, Skills và Flow Status."""
def _build_sidebar(self) -> QWidget: def _build_sidebar(self) -> QWidget:
@@ -66,9 +94,12 @@ class Co4ESidebarMixin:
col = _Col(self.side_split) col = _Col(self.side_split)
# --- WORKFLOWS --------------------------------------------------- # --- WORKFLOWS ---------------------------------------------------
self.wf_new_btn = QPushButton(tr("co4e.new")) # Bound, not set once: Co4ETab._retranslate reloads the sidebar's LIST
# CONTENTS, but these headings, buttons and tooltips are built here and
# nothing re-applied them — they stayed in the language of app start-up.
self.wf_new_btn = bind_text(QPushButton(), "co4e.new")
self.wf_new_btn.setIcon(icon("plus")) self.wf_new_btn.setIcon(icon("plus"))
self.wf_new_btn.setToolTip(tr("co4e.tt_new_wf")) bind_tip(self.wf_new_btn, "co4e.tt_new_wf")
self.wf_new_btn.setObjectName("co4eSectionAction") self.wf_new_btn.setObjectName("co4eSectionAction")
self.wf_new_btn.setFlat(True) self.wf_new_btn.setFlat(True)
self.wf_new_btn.setCursor(Qt.PointingHandCursor) self.wf_new_btn.setCursor(Qt.PointingHandCursor)
@@ -78,7 +109,7 @@ class Co4ESidebarMixin:
# Draggable: drag a flow onto the canvas to merge it in (Nova-style); # Draggable: drag a flow onto the canvas to merge it in (Nova-style);
# double-click loads it onto the canvas. # double-click loads it onto the canvas.
self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2) self.wf_list = _PaletteList(payload_role=Qt.UserRole + 2)
self.wf_list.setToolTip(tr("co4e.drag_hint")) bind_tip(self.wf_list, "co4e.drag_hint")
self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow) self.wf_list.itemDoubleClicked.connect(self._load_selected_workflow)
self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu) self.wf_list.setContextMenuPolicy(Qt.CustomContextMenu)
self.wf_list.customContextMenuRequested.connect(self._wf_context_menu) self.wf_list.customContextMenuRequested.connect(self._wf_context_menu)
@@ -93,8 +124,9 @@ class Co4ESidebarMixin:
wl.addLayout(wf_btns) wl.addLayout(wf_btns)
# Its own row: sharing one line with the three icon buttons cut "Chạy # Its own row: sharing one line with the three icon buttons cut "Chạy
# nền" down to "Chạ" as soon as the sidebar hit its narrow width. # nền" down to "Chạ" as soon as the sidebar hit its narrow width.
self.wf_runbg_btn = QPushButton(tr("co4e.run_bg")); self.wf_runbg_btn.setIcon(icon("play")) self.wf_runbg_btn = bind_text(QPushButton(), "co4e.run_bg")
self.wf_runbg_btn.setToolTip(tr("co4e.tt_run_bg")) self.wf_runbg_btn.setIcon(icon("play"))
bind_tip(self.wf_runbg_btn, "co4e.tt_run_bg")
self.wf_runbg_btn.clicked.connect(self._run_selected_in_background) self.wf_runbg_btn.clicked.connect(self._run_selected_in_background)
wl.addWidget(self.wf_runbg_btn) wl.addWidget(self.wf_runbg_btn)
col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3) col.addWidget(self._section("co4e.tab_workflows", wf_body, self.wf_new_btn), 3)
@@ -135,12 +167,12 @@ class Co4ESidebarMixin:
self.runs_more_btn.setIcon(icon("chevron-right")) self.runs_more_btn.setIcon(icon("chevron-right"))
self.runs_more_btn.setFixedWidth(30) self.runs_more_btn.setFixedWidth(30)
self.runs_more_btn.setFlat(True) self.runs_more_btn.setFlat(True)
self.runs_more_btn.setToolTip(tr("co4e.tt_runs_tab")) bind_tip(self.runs_more_btn, "co4e.tt_runs_tab")
self.runs_more_btn.clicked.connect(lambda: self._show_runs(True)) self.runs_more_btn.clicked.connect(lambda: self._show_runs(True))
runs_body = QWidget(); rl = QVBoxLayout(runs_body) runs_body = QWidget(); rl = QVBoxLayout(runs_body)
rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4) rl.setContentsMargins(0, 0, 0, 0); rl.setSpacing(4)
self.runs_side_list = QListWidget() self.runs_side_list = QListWidget()
self.runs_side_list.setToolTip(tr("co4e.tt_runs_tab")) bind_tip(self.runs_side_list, "co4e.tt_runs_tab")
self.runs_side_list.itemClicked.connect(self._on_side_run_clicked) self.runs_side_list.itemClicked.connect(self._on_side_run_clicked)
rl.addWidget(self.runs_side_list, 1) rl.addWidget(self.runs_side_list, 1)
col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2) col.addWidget(self._section("co4e.runs_tab", runs_body, self.runs_more_btn), 2)
@@ -202,7 +234,10 @@ class Co4ESidebarMixin:
v.addWidget(body, 1) v.addWidget(body, 1)
self._sections[key] = (head, body, stretch) self._sections[key] = (head, body, stretch)
self._sync_section_arrow(key) # bind_dynamic, not bind_text: the heading is the fold arrow plus the
# translated name in caps, so re-applying it means re-running the whole
# line rather than pushing one key into setText.
bind_dynamic(head, lambda k=key: self._sync_section_arrow(k))
return box return box
def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None: def _fold_section(self, key: str, body: QWidget, box: QWidget, on: bool) -> None:
"""Fold/unfold a section AND give its height back to the others. """Fold/unfold a section AND give its height back to the others.
@@ -223,7 +258,7 @@ class Co4ESidebarMixin:
head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper()) head.setText(("▾ " if head.isChecked() else "▸ ") + tr(key).upper())
def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton: def _icon_btn(self, icon_name: str, tip_key: str, slot) -> QPushButton:
"""Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục.""" """Dựng một nút icon nhỏ (rộng 34px) kèm tooltip cho hàng công cụ của mục."""
b = QPushButton(); b.setIcon(icon(icon_name)); b.setToolTip(tr(tip_key)) b = QPushButton(); b.setIcon(icon(icon_name)); bind_tip(b, tip_key)
b.setFixedWidth(34) b.setFixedWidth(34)
b.clicked.connect(slot) b.clicked.connect(slot)
return b return b
@@ -254,13 +289,16 @@ class Co4ESidebarMixin:
co4e._step_dict(step)) co4e._step_dict(step))
it.setData(Qt.UserRole + 1, ca.id) it.setData(Qt.UserRole + 1, ca.id)
self.agent_list.addItem(it) self.agent_list.addItem(it)
# Skills # Skills — the library is read ONCE here and both the names and the
# instructions come out of that one read (see _skill_prefix_lookup).
self.skill_list.clear() self.skill_list.clear()
for name in _skill_names(): all_skills = skills_mod.list_skills() + skills_mod.builtin_skills()
content = skills_mod.skill_prefix_for(name) skill_prefix = _skill_prefix_lookup(all_skills)
for skill in all_skills:
name = skill.name
payload = co4e._step_dict(co4e.Step( payload = co4e._step_dict(co4e.Step(
label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle", label=name, agent_slug=co4e.slugify(name), role="SKILL", icon="sparkle",
instructions=content, skills=[name])) instructions=skill_prefix(name), skills=[name]))
self.skill_list.addItem(self._palette_item(name, "sparkle", payload)) self.skill_list.addItem(self._palette_item(name, "sparkle", payload))
@staticmethod @staticmethod
def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem: def _palette_item(text: str, icon_name: str, payload: dict) -> QListWidgetItem:
+13 -4
View File
@@ -8,12 +8,16 @@ from __future__ import annotations
import re import re
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import QInputDialog, QMenu from PySide6.QtWidgets import QMenu
from ...core import co4e from ...core import co4e
from ...core.co4e import STEP_RUNNING
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import ask_text
from ...ui.icons import icon from ...ui.icons import icon
from ...presentation.co4e.co4e_chat_view import _skill_names from ...presentation.co4e.co4e_chat_view import _skill_names
_LOCKED_NODE_STATUSES = (STEP_RUNNING,)
class Co4EWorkflowCrudMixin: class Co4EWorkflowCrudMixin:
"""Phần tạo/mở/lưu/xoá luồng của Co4E Studio. """Phần tạo/mở/lưu/xoá luồng của Co4E Studio.
@@ -115,8 +119,8 @@ class Co4EWorkflowCrudMixin:
wf = co4e.get_workflow(ident) wf = co4e.get_workflow(ident)
if wf is None: if wf is None:
return return
name, ok = QInputDialog.getText(self, tr("co4e.rename"), tr("co4e.rename_prompt"), name, ok = ask_text(self, tr("co4e.rename"), tr("co4e.rename_prompt"),
text=wf.name) text=wf.name)
name = (name or "").strip() name = (name or "").strip()
if not ok or not name: if not ok or not name:
return return
@@ -163,10 +167,15 @@ class Co4EWorkflowCrudMixin:
self.canvas.add_palette_step(co4e.Step(label="New Step"), self.canvas.add_palette_step(co4e.Step(label="New Step"),
self.canvas.mapToScene(self.canvas.rect().center())) self.canvas.mapToScene(self.canvas.rect().center()))
def _on_node_selected(self, node_id: str) -> None: def _on_node_selected(self, node_id: str) -> None:
"""Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.""" """Chọn một node thì nạp bước đó vào bảng thuộc tính, tự mở bảng nếu đang gập.
Chỉ khoá ô nhập liệu khi bước ĐANG chạy (DF-002) — chạy xong rồi thì
vẫn sửa lại được bình thường.
"""
for n in self.canvas.nodes(): for n in self.canvas.nodes():
if n.id == node_id: if n.id == node_id:
self.config.load_step(node_id, n.data, _skill_names()) self.config.load_step(node_id, n.data, _skill_names())
self.config.set_locked(self.canvas.node_status(node_id) in _LOCKED_NODE_STATUSES)
if self._config_collapsed: if self._config_collapsed:
self._toggle_config() self._toggle_config()
return return
@@ -24,20 +24,21 @@ thứ tự kế thừa không ảnh hưởng hành vi (khác trường hợp
``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có ``co4e_canvas_widget.py``, nơi thứ tự mixin-trước-Qt-base là bắt buộc vì có
override trùng tên). override trùng tên).
Import trong từng method giữ nguyên y hệt bản gốc (kể cả các import cục bộ có Import trong từng method giữ nguyên y hệt bản gốc — chỉ số cấp `..` được nâng
vẻ thừa như ``from PySide6.QtWidgets import QInputDialog`` lặp lại bên trong lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang
``_add_subagent``/``_edit_subagent`` dù đã có ở top-level) — chỉ số cấp `..` ``presentation/co4e/`` (cách gốc 3 cấp). Riêng các lời gọi ``QInputDialog``
được nâng lên `...` cho khớp việc file dời từ ``ui/`` (cách gốc 2 cấp) sang đã chuyển sang ``ui.dialog_buttons``: hàm tĩnh của Qt tự dựng hộp thoại bên
``presentation/co4e/`` (cách gốc 3 cấp). trong nên nút "Cancel" của nó luôn là tiếng Anh.
""" """
from __future__ import annotations from __future__ import annotations
from typing import List from typing import List
from PySide6.QtWidgets import QInputDialog, QListWidgetItem from PySide6.QtWidgets import QListWidgetItem
from ...core.co4e import SubAgent from ...core.co4e import SubAgent
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import ask_item, ask_multiline, ask_text
class _StepConfigActionsMixin: class _StepConfigActionsMixin:
@@ -67,14 +68,12 @@ class _StepConfigActionsMixin:
"""Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó).""" """Thêm một sub-agent vào bước đang chọn (chạy song song trong bước đó)."""
if self._step is None: if self._step is None:
return return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names() names = self._available_agent_names()
if names: if names:
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"), name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names, 0, True) # editable: can type a new one names, 0, True) # editable: can type a new one
else: else:
name, ok = QInputDialog.getText(self, tr("co4e.pick_agent"), tr("co4e.pick_agent")) name, ok = ask_text(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"))
name = (name or "").strip() name = (name or "").strip()
if not ok or not name: if not ok or not name:
return return
@@ -89,13 +88,11 @@ class _StepConfigActionsMixin:
row = self.sub_list.row(item) row = self.sub_list.row(item)
if not (0 <= row < len(self._step.sub_agents)): if not (0 <= row < len(self._step.sub_agents)):
return return
from PySide6.QtWidgets import QInputDialog
names = self._available_agent_names() names = self._available_agent_names()
cur = self._step.sub_agents[row].agent cur = self._step.sub_agents[row].agent
start = names.index(cur) if cur in names else 0 start = names.index(cur) if cur in names else 0
name, ok = QInputDialog.getItem(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"), name, ok = ask_item(self, tr("co4e.pick_agent"), tr("co4e.pick_agent"),
names or [cur], start, True) names or [cur], start, True)
name = (name or "").strip() name = (name or "").strip()
if ok and name: if ok and name:
self._step.sub_agents[row].agent = name self._step.sub_agents[row].agent = name
@@ -151,7 +148,7 @@ class _StepConfigActionsMixin:
role = self.role_edit.text().strip() role = self.role_edit.text().strip()
if not name and not role: if not name and not role:
return return
hint, ok = QInputDialog.getMultiLineText( hint, ok = ask_multiline(
self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label")) self, tr("co4e.ai_draft_hint_title"), tr("co4e.ai_draft_hint_label"))
if not ok: if not ok:
return return
+64 -33
View File
@@ -38,8 +38,8 @@ from PySide6.QtWidgets import (
) )
from ...config import PROVIDER_LABELS from ...config import PROVIDER_LABELS
from ...core.co4e import PERMISSION_PRESETS, Step from ...core.co4e import PERMISSION_PRESETS, STEP_DONE, STEP_RUNNING, Step
from ...i18n import tr from ...i18n import bind_items, bind_placeholder, bind_text, bind_tip, tr
from ...ui.icons import icon, icon_picker_combo from ...ui.icons import icon, icon_picker_combo
from .node_property_actions_mixin import _StepConfigActionsMixin from .node_property_actions_mixin import _StepConfigActionsMixin
from .step_config_section import _add_section from .step_config_section import _add_section
@@ -65,6 +65,8 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self._step: Optional[Step] = None self._step: Optional[Step] = None
self._node_id = "" self._node_id = ""
self._loading = False self._loading = False
self._ctx_available = ctx is not None
self._locked = False
self.setWidgetResizable(True) self.setWidgetResizable(True)
host = QWidget() host = QWidget()
self.setWidget(host) self.setWidget(host)
@@ -77,30 +79,34 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# (skills/files/sub-agents). No tabs/accordion: every group's border # (skills/files/sub-agents). No tabs/accordion: every group's border
# and heading are what separate it from its neighbours, and all three # and heading are what separate it from its neighbours, and all three
# are on screen (or one scroll away) at once. # are on screen (or one scroll away) at once.
form, _basic_card = _add_section(outer, tr("co4e.tab_basic")) form, _basic_card = _add_section(outer, "co4e.tab_basic")
self.label_edit = QLineEdit() self.label_edit = QLineEdit()
self.label_edit.textChanged.connect(self._on_edit) self.label_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_label"), self.label_edit) form.addRow(bind_text(QLabel(), "co4e.f_label"), self.label_edit)
self.role_edit = QLineEdit() self.role_edit = QLineEdit()
self.role_edit.textChanged.connect(self._on_edit) self.role_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_role"), self.role_edit) form.addRow(bind_text(QLabel(), "co4e.f_role"), self.role_edit)
# Dropdown of every icon in the registry (Monitoring's Icon Management # Dropdown of every icon in the registry (Monitoring's Icon Management
# set + built-ins), each row previewing its actual glyph — still # set + built-ins), each row previewing its actual glyph — still
# editable so a not-yet-added custom name can be typed directly. # editable so a not-yet-added custom name can be typed directly.
self.icon_edit = icon_picker_combo() self.icon_edit = icon_picker_combo()
self.icon_edit.lineEdit().setPlaceholderText(tr("co4e.f_icon_placeholder")) # Kept on self because the combo's line edit belongs to C++: a binding
# holds its widget weakly, so with no owner on this side the Python
# wrapper could be collected and the binding silently dropped.
self._icon_line = self.icon_edit.lineEdit()
bind_placeholder(self._icon_line, "co4e.f_icon_placeholder")
self.icon_edit.currentTextChanged.connect(self._on_edit) self.icon_edit.currentTextChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_icon"), self.icon_edit) form.addRow(bind_text(QLabel(), "co4e.f_icon"), self.icon_edit)
self.instructions_edit = QPlainTextEdit() self.instructions_edit = QPlainTextEdit()
self.instructions_edit.setMaximumHeight(120) self.instructions_edit.setMaximumHeight(120)
self.instructions_edit.textChanged.connect(self._on_edit) self.instructions_edit.textChanged.connect(self._on_edit)
self.gen_btn = QPushButton(tr("co4e.ai_draft")) self.gen_btn = bind_text(QPushButton(), "co4e.ai_draft")
self.gen_btn.setIcon(icon("sparkle")) self.gen_btn.setIcon(icon("sparkle"))
self.gen_btn.setToolTip(tr("co4e.ai_draft_tooltip")) bind_tip(self.gen_btn, "co4e.ai_draft_tooltip")
self.gen_btn.setEnabled(ctx is not None) self.gen_btn.setEnabled(ctx is not None)
self.gen_btn.clicked.connect(self._ai_draft) self.gen_btn.clicked.connect(self._ai_draft)
instr_box = QWidget() instr_box = QWidget()
@@ -108,17 +114,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
ib.setContentsMargins(0, 0, 0, 0) ib.setContentsMargins(0, 0, 0, 0)
ib.addWidget(self.instructions_edit) ib.addWidget(self.instructions_edit)
ib.addWidget(self.gen_btn, alignment=Qt.AlignRight) ib.addWidget(self.gen_btn, alignment=Qt.AlignRight)
form.addRow(tr("co4e.f_instructions"), instr_box) form.addRow(bind_text(QLabel(), "co4e.f_instructions"), instr_box)
# Extra context — free-text background/info fed to the step at run time # Extra context — free-text background/info fed to the step at run time
# (in addition to instructions, attachments and upstream outputs). # (in addition to instructions, attachments and upstream outputs).
self.context_edit = QPlainTextEdit() self.context_edit = QPlainTextEdit()
self.context_edit.setMaximumHeight(90) self.context_edit.setMaximumHeight(90)
self.context_edit.setPlaceholderText(tr("co4e.f_context_placeholder")) bind_placeholder(self.context_edit, "co4e.f_context_placeholder")
self.context_edit.textChanged.connect(self._on_edit) self.context_edit.textChanged.connect(self._on_edit)
form.addRow(tr("co4e.f_context"), self.context_edit) form.addRow(bind_text(QLabel(), "co4e.f_context"), self.context_edit)
form2, _model_card = _add_section(outer, tr("co4e.tab_model_perm")) form2, _model_card = _add_section(outer, "co4e.tab_model_perm")
model_row = QHBoxLayout() model_row = QHBoxLayout()
self.model_combo = QComboBox() self.model_combo = QComboBox()
@@ -126,48 +132,52 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.model_combo.editTextChanged.connect(self._on_edit) self.model_combo.editTextChanged.connect(self._on_edit)
self.load_models_btn = QPushButton() self.load_models_btn = QPushButton()
self.load_models_btn.setIcon(icon("download")) self.load_models_btn.setIcon(icon("download"))
self.load_models_btn.setToolTip(tr("co4e.load_models_tooltip")) bind_tip(self.load_models_btn, "co4e.load_models_tooltip")
self.load_models_btn.clicked.connect(self._load_models) self.load_models_btn.clicked.connect(self._load_models)
self.load_models_btn.setEnabled(ctx is not None) self.load_models_btn.setEnabled(ctx is not None)
model_row.addWidget(self.model_combo, 1) model_row.addWidget(self.model_combo, 1)
model_row.addWidget(self.load_models_btn) model_row.addWidget(self.load_models_btn)
mrow = QWidget(); mrow.setLayout(model_row) mrow = QWidget(); mrow.setLayout(model_row)
form2.addRow(tr("co4e.f_model"), mrow) form2.addRow(bind_text(QLabel(), "co4e.f_model"), mrow)
self.perm_combo = QComboBox() self.perm_combo = QComboBox()
for preset in PERMISSION_PRESETS: perm_keys = [f"co4e.perm.{preset}" for preset in PERMISSION_PRESETS]
self.perm_combo.addItem(tr(f"co4e.perm.{preset}"), preset) for preset, key in zip(PERMISSION_PRESETS, perm_keys):
self.perm_combo.addItem(tr(key), preset)
# Only the visible labels follow the language — the data column stays
# the preset id that ``_on_edit`` persists onto the Step.
bind_items(self.perm_combo, perm_keys)
self.perm_combo.currentIndexChanged.connect(self._on_edit) self.perm_combo.currentIndexChanged.connect(self._on_edit)
form2.addRow(tr("co4e.f_permission"), self.perm_combo) form2.addRow(bind_text(QLabel(), "co4e.f_permission"), self.perm_combo)
verify_row = QHBoxLayout() verify_row = QHBoxLayout()
self.verify_chk = QCheckBox(tr("co4e.f_self_verify")) self.verify_chk = bind_text(QCheckBox(), "co4e.f_self_verify")
self.verify_chk.toggled.connect(self._on_edit) self.verify_chk.toggled.connect(self._on_edit)
self.rounds_spin = QSpinBox() self.rounds_spin = QSpinBox()
self.rounds_spin.setRange(1, 5) self.rounds_spin.setRange(1, 5)
self.rounds_spin.valueChanged.connect(self._on_edit) self.rounds_spin.valueChanged.connect(self._on_edit)
verify_row.addWidget(self.verify_chk) verify_row.addWidget(self.verify_chk)
verify_row.addWidget(QLabel(tr("co4e.f_verify_rounds"))) verify_row.addWidget(bind_text(QLabel(), "co4e.f_verify_rounds"))
verify_row.addWidget(self.rounds_spin) verify_row.addWidget(self.rounds_spin)
verify_row.addStretch(1) verify_row.addStretch(1)
vrow = QWidget(); vrow.setLayout(verify_row) vrow = QWidget(); vrow.setLayout(verify_row)
form2.addRow("", vrow) form2.addRow("", vrow)
form3, _skills_card = _add_section(outer, tr("co4e.tab_skills_files")) form3, _skills_card = _add_section(outer, "co4e.tab_skills_files")
# Skills checklist (registry skills) # Skills checklist (registry skills)
self.skills_list = QListWidget() self.skills_list = QListWidget()
self.skills_list.setMaximumHeight(110) self.skills_list.setMaximumHeight(110)
self.skills_list.itemChanged.connect(self._on_edit) self.skills_list.itemChanged.connect(self._on_edit)
form3.addRow(tr("co4e.f_skills"), self.skills_list) form3.addRow(bind_text(QLabel(), "co4e.f_skills"), self.skills_list)
# Attachments — files whose extracted text is fed to this step at run time. # Attachments — files whose extracted text is fed to this step at run time.
self.attach_list = QListWidget() self.attach_list = QListWidget()
self.attach_list.setMaximumHeight(80) self.attach_list.setMaximumHeight(80)
self.attach_add_btn = QPushButton(tr("co4e.attach_add")) self.attach_add_btn = bind_text(QPushButton(), "co4e.attach_add")
self.attach_add_btn.setIcon(icon("plus")) self.attach_add_btn.setIcon(icon("plus"))
self.attach_add_btn.clicked.connect(self._add_attachment) self.attach_add_btn.clicked.connect(self._add_attachment)
self.attach_del_btn = QPushButton(tr("co4e.attach_remove")) self.attach_del_btn = bind_text(QPushButton(), "co4e.attach_remove")
self.attach_del_btn.setIcon(icon("trash")) self.attach_del_btn.setIcon(icon("trash"))
self.attach_del_btn.clicked.connect(self._del_attachment) self.attach_del_btn.clicked.connect(self._del_attachment)
att_btns = QHBoxLayout() att_btns = QHBoxLayout()
@@ -175,7 +185,7 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
att_btns.addWidget(self.attach_del_btn) att_btns.addWidget(self.attach_del_btn)
att_btns.addStretch(1) att_btns.addStretch(1)
abtn = QWidget(); abtn.setLayout(att_btns) abtn = QWidget(); abtn.setLayout(att_btns)
form3.addRow(tr("co4e.f_attachments"), self.attach_list) form3.addRow(bind_text(QLabel(), "co4e.f_attachments"), self.attach_list)
form3.addRow("", abtn) form3.addRow("", abtn)
# Parallel sub-agents get their OWN section — same header style as # Parallel sub-agents get their OWN section — same header style as
@@ -183,14 +193,14 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# Skills & Tệp, since it's really a distinct group, just one that # Skills & Tệp, since it's really a distinct group, just one that
# only applies to parallel-variant steps. load_step() hides the whole # only applies to parallel-variant steps. load_step() hides the whole
# card for a non-parallel step (see is_par below). # card for a non-parallel step (see is_par below).
form4, self._parallel_card = _add_section(outer, tr("co4e.f_subagents")) form4, self._parallel_card = _add_section(outer, "co4e.f_subagents")
self.sub_list = QListWidget() self.sub_list = QListWidget()
self.sub_list.setMaximumHeight(90) self.sub_list.setMaximumHeight(90)
self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent self.sub_list.itemDoubleClicked.connect(self._edit_subagent) # re-pick agent
self.sub_add_btn = QPushButton(tr("co4e.add_subagent")) self.sub_add_btn = bind_text(QPushButton(), "co4e.add_subagent")
self.sub_add_btn.setIcon(icon("plus")) self.sub_add_btn.setIcon(icon("plus"))
self.sub_add_btn.clicked.connect(self._add_subagent) self.sub_add_btn.clicked.connect(self._add_subagent)
self.sub_del_btn = QPushButton(tr("co4e.del_subagent")) self.sub_del_btn = bind_text(QPushButton(), "co4e.del_subagent")
self.sub_del_btn.setIcon(icon("trash")) self.sub_del_btn.setIcon(icon("trash"))
self.sub_del_btn.clicked.connect(self._del_subagent) self.sub_del_btn.clicked.connect(self._del_subagent)
sub_btns = QHBoxLayout() sub_btns = QHBoxLayout()
@@ -203,17 +213,17 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
# Footer actions — one compact row (Run · Run from here · Delete), # Footer actions — one compact row (Run · Run from here · Delete),
# kept below every section, not inside one of the cards. # kept below every section, not inside one of the cards.
self.run_btn = QPushButton(tr("co4e.run")) self.run_btn = bind_text(QPushButton(), "co4e.run")
self.run_btn.setIcon(icon("play")) self.run_btn.setIcon(icon("play"))
self.run_btn.setToolTip(tr("co4e.run_this_step")) bind_tip(self.run_btn, "co4e.run_this_step")
self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id)) self.run_btn.clicked.connect(lambda: self.run_node.emit(self._node_id))
self.run_from_btn = QPushButton(tr("co4e.run_from_here")) self.run_from_btn = bind_text(QPushButton(), "co4e.run_from_here")
self.run_from_btn.setToolTip(tr("co4e.run_from_here")) bind_tip(self.run_from_btn, "co4e.run_from_here")
self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id)) self.run_from_btn.clicked.connect(lambda: self.run_from.emit(self._node_id))
self.del_btn = QPushButton() self.del_btn = QPushButton()
self.del_btn.setIcon(icon("trash")) self.del_btn.setIcon(icon("trash"))
self.del_btn.setObjectName("danger") self.del_btn.setObjectName("danger")
self.del_btn.setToolTip(tr("co4e.delete_step")) bind_tip(self.del_btn, "co4e.delete_step")
self.del_btn.setFixedWidth(38) self.del_btn.setFixedWidth(38)
self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id)) self.del_btn.clicked.connect(lambda: self.delete_node.emit(self._node_id))
foot = QHBoxLayout() foot = QHBoxLayout()
@@ -281,6 +291,27 @@ class StepConfigPanel(_StepConfigActionsMixin, QScrollArea):
self.sub_list.addItem(sub.agent) self.sub_list.addItem(sub.agent)
self._loading = False self._loading = False
def set_locked(self, locked: bool) -> None:
"""Khoá/mở khoá các trường chỉnh sửa theo trạng thái chạy của bước.
Chỉ khoá khi bước ĐANG chạy — tránh sửa nhầm cấu hình trong lúc chưa
biết kết quả (DF-002: trước đây còn khoá cả bước đã chạy xong, khiến
không sửa lại được sau khi run xong). Nút Chạy/Chạy từ đây/Xoá bước
vẫn hoạt động bình thường khi khoá — chỉ ô nhập liệu bị khoá, không
phải cả panel.
"""
self._locked = locked
editable = not locked
for w in (self.label_edit, self.role_edit, self.icon_edit,
self.instructions_edit, self.context_edit,
self.model_combo, self.perm_combo, self.verify_chk,
self.rounds_spin, self.skills_list,
self.attach_add_btn, self.attach_del_btn,
self.sub_add_btn, self.sub_del_btn, self.sub_list):
w.setEnabled(editable)
self.gen_btn.setEnabled(editable and self._ctx_available)
self.load_models_btn.setEnabled(editable and self._ctx_available)
def clear_step(self) -> None: def clear_step(self) -> None:
"""Xoá bảng khi không có bước nào được chọn.""" """Xoá bảng khi không có bước nào được chọn."""
self._step = None self._step = None
+5 -3
View File
@@ -29,7 +29,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget from PySide6.QtWidgets import QPushButton, QVBoxLayout, QWidget
from ...i18n import tr from ...i18n import bind_text, bind_tip
from .palette_list import _PaletteList from .palette_list import _PaletteList
@@ -50,8 +50,10 @@ class SkillsListPanel(QWidget):
cái gì. cái gì.
""" """
super().__init__(parent) super().__init__(parent)
self.manage_btn = QPushButton(tr("co4e.manage_skills")) # Bound, like AgentListPanel's: the panel owns how its own button reads,
self.manage_btn.setToolTip(tr("co4e.tt_manage_skills")) # so no embedder has to remember it in a retranslate method.
self.manage_btn = bind_text(QPushButton(), "co4e.manage_skills")
bind_tip(self.manage_btn, "co4e.tt_manage_skills")
self.manage_btn.setObjectName("co4eSectionAction") self.manage_btn.setObjectName("co4eSectionAction")
self.manage_btn.setFlat(True) self.manage_btn.setFlat(True)
self.manage_btn.setCursor(Qt.PointingHandCursor) self.manage_btn.setCursor(Qt.PointingHandCursor)
+16 -4
View File
@@ -26,6 +26,7 @@ from __future__ import annotations
from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal from PySide6.QtCore import Qt, QEasingCurve, QPropertyAnimation, Signal
from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget from PySide6.QtWidgets import QFormLayout, QLabel, QVBoxLayout, QWidget
from ...i18n import bind_dynamic, tr
from ...theme import current_palette from ...theme import current_palette
_SECTION_ANIM_MS = 180 _SECTION_ANIM_MS = 180
@@ -64,7 +65,7 @@ class _SectionHeader(QLabel):
super().showEvent(event) super().showEvent(event)
def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]: def _add_section(outer: QVBoxLayout, title_key: str) -> tuple[QFormLayout, QWidget]:
"""One group of fields, collapsed to just its heading by default and """One group of fields, collapsed to just its heading by default and
independently expandable, so a long step config reads as a short list of independently expandable, so a long step config reads as a short list of
group names until you open the one you need. Deliberately bare — no card group names until you open the one you need. Deliberately bare — no card
@@ -74,7 +75,11 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
the group's rows to ``form``; ``card`` is the whole section (header + the group's rows to ``form``; ``card`` is the whole section (header +
body) — hide it to remove the group entirely (e.g. for a section that body) — hide it to remove the group entirely (e.g. for a section that
only applies to some steps), rather than hiding individual rows inside only applies to some steps), rather than hiding individual rows inside
an always-visible header.""" an always-visible header.
Nhận KHOÁ dịch, không nhận chuỗi đã dịch: nhãn mục do hàm này tự dựng nên
nơi gọi không giữ tham chiếu nào để áp lại: truyền ``tr(...)`` vào đây thì
bốn tiêu đề đứng nguyên ở ngôn ngữ lúc dựng panel."""
p = current_palette() p = current_palette()
card = QWidget() card = QWidget()
card_lay = QVBoxLayout(card) card_lay = QVBoxLayout(card)
@@ -96,7 +101,6 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
# diacritics. # diacritics.
header.ensurePolished() header.ensurePolished()
header.setFixedHeight(header.fontMetrics().height()) header.setFixedHeight(header.fontMetrics().height())
header.setText(f"▶ {title}")
card_lay.addWidget(header) card_lay.addWidget(header)
body = QWidget() body = QWidget()
@@ -112,6 +116,14 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
is_open = False is_open = False
def _sync_header() -> None:
"""Nhãn mục: dấu gập/mở hiện tại + tiêu đề theo ngôn ngữ đang chọn."""
header.setText(f"{'▼' if is_open else '▶'} {tr(title_key)}")
# Ràng buộc ĐỘNG chứ không bind cứng một chuỗi: nhãn này mang cả trạng thái
# gập/mở, nên bind cứng sẽ trả nó về ▶ mỗi lần người dùng đổi ngôn ngữ.
bind_dynamic(header, _sync_header)
def _on_finished() -> None: def _on_finished() -> None:
"""Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều """Hiệu ứng gập/mở chạy xong: bỏ trần chiều cao khi đang mở, để bước có nhiều
trường không bị cắt cụt. trường không bị cắt cụt.
@@ -130,7 +142,7 @@ def _add_section(outer: QVBoxLayout, title: str) -> tuple[QFormLayout, QWidget]:
"""Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng.""" """Lật trạng thái gập/mở của một mục và chạy hiệu ứng tương ứng."""
nonlocal is_open nonlocal is_open
is_open = not is_open is_open = not is_open
header.setText(f"{'▼' if is_open else '▶'} {title}") _sync_header()
anim.stop() anim.stop()
if is_open: if is_open:
body.setVisible(True) body.setVisible(True)
+3 -3
View File
@@ -116,9 +116,9 @@ class HabitsWidget(QWidget):
"""Apply an AI-suggested cost-saving strategy AFTER the user """Apply an AI-suggested cost-saving strategy AFTER the user
approves: turn on auto-compress and compress earlier (lower approves: turn on auto-compress and compress earlier (lower
threshold) + compress content before sending it to the agent.""" threshold) + compress content before sending it to the agent."""
from PySide6.QtWidgets import QMessageBox from ...ui.dialog_buttons import confirm
if QMessageBox.question(self, tr("dashboard.strategy_title"), if not confirm(self, tr("dashboard.strategy_title"),
tr("dashboard.strategy_confirm")) != QMessageBox.Yes: tr("dashboard.strategy_confirm")):
return return
cx = self.ctx.config.data.setdefault("context", {}) cx = self.ctx.config.data.setdefault("context", {})
cx["auto_compact"] = True cx["auto_compact"] = True
@@ -121,6 +121,13 @@ class UsageChartWidget(QWidget):
def retranslate(self) -> None: def retranslate(self) -> None:
"""Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip.""" """Áp lại chữ theo ngôn ngữ đang chọn cho nhãn và tooltip."""
# setItemText, chứ không clear()+addItem(): cột data của hai combo này
# là thứ quyết định kỳ và chỉ số đang xem, dựng lại danh sách sẽ reset cả
# hai. Khoá dịch suy ra từ chính cột data nên không phải chép lại danh
# sách giá trị ở hai nơi.
for combo, prefix in ((self.gran_combo, "gran"), (self.metric_combo, "metric")):
for i in range(combo.count()):
combo.setItemText(i, tr(f"dashboard.{prefix}_{combo.itemData(i)}"))
self.currency_lbl.setText(tr("monitoring.overview_currency")) self.currency_lbl.setText(tr("monitoring.overview_currency"))
self.currency_combo.setToolTip(tr("dashboard.currency_tooltip")) self.currency_combo.setToolTip(tr("dashboard.currency_tooltip"))
self._chart_title.setText(tr("dashboard.chart_title")) self._chart_title.setText(tr("dashboard.chart_title"))
+3 -3
View File
@@ -290,9 +290,9 @@ class AiEditPipeline:
self.pending = None self.pending = None
self._owner.show_confirm_row(False) self._owner.show_confirm_row(False)
if p.get("image_gens"): if p.get("image_gens"):
from PySide6.QtWidgets import QMessageBox from ...ui.dialog_buttons import confirm
if QMessageBox.question(self._owner, tr("folder.ai_image_confirm_title"), if not confirm(self._owner, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm_gen")) != QMessageBox.Yes: tr("folder.ai_image_confirm_gen")):
self._owner.status_message.emit(tr("folder.ai_image_declined")) self._owner.status_message.emit(tr("folder.ai_image_declined"))
return return
self._generate_then_finalize(p) self._generate_then_finalize(p)
+44 -5
View File
@@ -21,9 +21,9 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton, QVBoxLayout, QWidget, QComboBox, QHBoxLayout, QLabel, QPlainTextEdit, QPushButton, QVBoxLayout, QWidget,
) )
from PySide6.QtCore import Signal from PySide6.QtCore import Qt, Signal
from cowork_local.i18n import on_language_changed, tr from cowork_local.i18n import on_language_changed, tr
from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver from cowork_local.presentation.folder.ai_edit_model_resolver import AiEditModelResolver
@@ -32,6 +32,45 @@ from cowork_local.theme import current_palette
from cowork_local.ui.chat_view import ChatView from cowork_local.ui.chat_view import ChatView
class _AutoExpandInput(QPlainTextEdit):
"""Instruction box: grows with content (1..~6 lines, then scrolls), Enter
submits, Shift+Enter inserts a newline — same convention as the Cowork
composer (``presentation/chat/chat_input_box.py::_Input``), minus its
``/skill``/``/agent`` popups and drag-drop attachment handling, which
don't apply to a single AI-edit instruction. DF-008: a fixed-height
single-line ``QLineEdit`` read as cramped for a full instruction; this
replaces it instead of just nudging the height up further."""
submit = Signal()
MIN_HEIGHT = 36 # matches the old QLineEdit's bumped-up height
MAX_HEIGHT = 140 # ~6 lines, then it scrolls instead of growing further
def __init__(self, parent=None):
super().__init__(parent)
self.setTabChangesFocus(True) # Tab moves focus, doesn't insert a tab
self.setVerticalScrollBarPolicy(Qt.ScrollBarAsNeeded)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.textChanged.connect(self._adjust_height)
self._adjust_height()
def _adjust_height(self) -> None:
# QPlainTextEdit reports the document height in LINES, not pixels —
# convert via line spacing (same approach as chat_input_box.py).
lines = self.document().size().height() or 1
line_px = self.fontMetrics().lineSpacing()
h = int(lines * line_px + 2 * self.frameWidth() + 12)
h = max(self.MIN_HEIGHT, min(self.MAX_HEIGHT, h))
if h != self.height():
self.setFixedHeight(h)
def keyPressEvent(self, e) -> None: # noqa: N802
if e.key() in (Qt.Key_Return, Qt.Key_Enter) and not (e.modifiers() & Qt.ShiftModifier):
self.submit.emit()
return
super().keyPressEvent(e)
class AiFileEditorDialog(QWidget): class AiFileEditorDialog(QWidget):
"""Collapsible panel: a Cowork-style inline chat timeline, this panel's """Collapsible panel: a Cowork-style inline chat timeline, this panel's
OWN model picker + routing toggle, an instruction box, and an Apply/ OWN model picker + routing toggle, an instruction box, and an Apply/
@@ -94,9 +133,9 @@ class AiFileEditorDialog(QWidget):
ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch) ctx, self.ai_model_combo, self.ai_chat.add_status, self._confirm_routing_switch)
row = QHBoxLayout() row = QHBoxLayout()
self.ai_input = QLineEdit() self.ai_input = _AutoExpandInput()
self.ai_input.setPlaceholderText(tr("folder.ai_placeholder")) self.ai_input.setPlaceholderText(tr("folder.ai_placeholder"))
self.ai_input.returnPressed.connect(self._ai_send) self.ai_input.submit.connect(self._ai_send)
row.addWidget(self.ai_input, 1) row.addWidget(self.ai_input, 1)
self.ai_send_btn = QPushButton(tr("folder.ai_send")) self.ai_send_btn = QPushButton(tr("folder.ai_send"))
self.ai_send_btn.setObjectName("primary") self.ai_send_btn.setObjectName("primary")
@@ -170,7 +209,7 @@ class AiFileEditorDialog(QWidget):
if not self.preview.root: if not self.preview.root:
self.ai_chat.add_error(tr("folder.ai_no_file")) self.ai_chat.add_error(tr("folder.ai_no_file"))
return return
instruction = self.ai_input.text().strip() instruction = self.ai_input.toPlainText().strip()
if not instruction: if not instruction:
return return
self.ai_input.clear() self.ai_input.clear()
@@ -276,10 +276,9 @@ class OfficeDocumentRenderer:
from cowork_local.core import pptx_edit from cowork_local.core import pptx_edit
o = self._owner o = self._owner
if not skip_confirm and pptx_edit.image_change_requested(content): if not skip_confirm and pptx_edit.image_change_requested(content):
from PySide6.QtWidgets import QMessageBox from cowork_local.ui.dialog_buttons import confirm
ok = QMessageBox.question(o, tr("folder.ai_image_confirm_title"), if not confirm(o, tr("folder.ai_image_confirm_title"),
tr("folder.ai_image_confirm")) tr("folder.ai_image_confirm")):
if ok != QMessageBox.Yes:
o.status_message.emit(tr("folder.ai_image_declined")) o.status_message.emit(tr("folder.ai_image_declined"))
return False return False
pptx_edit.apply_text_to_pptx(o.current_file, content) pptx_edit.apply_text_to_pptx(o.current_file, content)
@@ -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.""" """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() 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 def hideEvent(self, e): # noqa: N802
# Leaving the GraphRAG tab → drop the temporary extracted info. # 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.""" """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 __future__ import annotations
from datetime import date, timedelta
from typing import List from typing import List
from PySide6.QtCore import QTimer, Signal from PySide6.QtCore import QTimer, Signal
@@ -25,11 +26,25 @@ from .tabs.overview_tab import OverviewTab
from .tabs.security_events_tab import SecurityEventsTab from .tabs.security_events_tab import SecurityEventsTab
_REFRESH_MS = 3000 _REFRESH_MS = 3000
# Comfortably larger than any realistic audit-log size — the event tables # Comfortably larger than any realistic audit-log size for the WINDOW of
# have never had pagination controls, so every tab still shows "all matching # events _load_events() now actually reads (see _LOG_WINDOW_DAYS below) — this
# events" exactly like before; MonitoringQueryService's pagination support # is MonitoringQueryService's query-side page size, kept unbounded so it
# is exercised for real here, just not surfaced as UI (yet). # always returns every matching event within the window; the user-facing
# "Số dòng/trang" control (DF-006 — see shared/event_table.py::set_page_size,
# shared/filter_scaffold.py::build_filter_scaffold's with_page_size) trims
# that down for DISPLAY, client-side, per event tab.
_UNBOUNDED_PAGE_SIZE = 100_000 _UNBOUNDED_PAGE_SIZE = 100_000
# _load_events() re-reads the audit log from disk every _REFRESH_MS (3s) via
# _auto_refresh(), and audit_log.load_events()/load_shared_audit_events() are
# day-sharded JSONL — unbounded start/end means EVERY day file ever written
# gets re-read and re-parsed on EVERY tick, which is what actually made
# Monitoring "gây nặng khi log lớn" (see DF-006): the slowness was never in
# rendering (EventTable paginates client-side, 5-100 rows/page — see
# shared/event_table.py::_DEFAULT_PAGE_SIZE), it was this repeated full-history
# read.
# 30 days is a live-monitoring window, not a hard retention limit — nothing
# is deleted, older days are simply not re-read on every 3s tick.
_LOG_WINDOW_DAYS = 30
class MonitoringTab(QWidget): class MonitoringTab(QWidget):
@@ -259,14 +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 Có cấu hình thư mục chia sẻ VÀ đọc ra được dữ liệu thì dùng nó, để cả đội
nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này. nhìn chung một bức tranh; rỗng thì rơi về nhật ký của máy này.
Chỉ đọc ``_LOG_WINDOW_DAYS`` ngày gần nhất — cả hai nguồn đều lưu theo
file JSONL từng ngày, nên bounding ở đây tránh việc đọc lại TOÀN BỘ
lịch sử mỗi 3 giây (xem ``_auto_refresh``), là nguyên nhân thật của
DF-006 (gây nặng khi log lớn).
""" """
start = date.today() - timedelta(days=_LOG_WINDOW_DAYS)
shared_dir = self.ctx.config.shared_dir shared_dir = self.ctx.config.shared_dir
if shared_dir: if shared_dir:
from ...core import telemetry_shared from ...core import telemetry_shared
shared_events = telemetry_shared.load_shared_audit_events(shared_dir) shared_events = telemetry_shared.load_shared_audit_events(shared_dir, start=start)
if shared_events: if shared_events:
return shared_events return shared_events
return audit_log.load_events() return audit_log.load_events(start=start)
def _apply_events_to_event_tabs(self, events: List[dict]) -> None: def _apply_events_to_event_tabs(self, events: List[dict]) -> None:
"""Filters the ALREADY-LOADED event list (see ``_load_events`` — one """Filters the ALREADY-LOADED event list (see ``_load_events`` — one
+63 -4
View File
@@ -6,7 +6,7 @@ from __future__ import annotations
from typing import List, Optional from typing import List, Optional
from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt from PySide6.QtCore import QEvent, QObject, QRect, QSize, Qt, Signal
from PySide6.QtGui import QBrush, QColor from PySide6.QtGui import QBrush, QColor
from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget from PySide6.QtWidgets import QHeaderView, QTableWidget, QTableWidgetItem, QWidget
@@ -17,7 +17,8 @@ from ....ui.icons import DOT_GREEN, DOT_RED, icon
from .badges import action_label from .badges import action_label
from .formatters import agent_avatar_icon, fmt_event_time from .formatters import agent_avatar_icon, fmt_event_time
_MAX_ROWS = 300 _DEFAULT_PAGE_SIZE = 20
PAGE_SIZE_OPTIONS = (5, 10, 20, 50, 100)
class _TimeItem(QTableWidgetItem): class _TimeItem(QTableWidgetItem):
@@ -62,6 +63,12 @@ class EventTable(QTableWidget):
"secret_in_output": "warning", "secret_in_output": "warning",
} }
# Emitted whenever the rendered page changes (new data, page-size change,
# or prev/next navigation) — args are (current_page, page_count), both
# 1-based-friendly in that current_page is 0-indexed but page_count is a
# plain count. filter_scaffold.py's pager label/buttons listen to this.
page_changed = Signal(int, int)
def __init__(self, show_result: bool = True): def __init__(self, show_result: bool = True):
# Security Events drops the result column entirely (see _ACTION_TINTS). # Security Events drops the result column entirely (see _ACTION_TINTS).
"""Bảng sự kiện dùng chung của các tab Giám sát. """Bảng sự kiện dùng chung của các tab Giám sát.
@@ -70,6 +77,10 @@ class EventTable(QTableWidget):
là thất bại nên cột ấy chỉ tốn chỗ. là thất bại nên cột ấy chỉ tốn chỗ.
""" """
self._show_result = show_result self._show_result = show_result
self._page_size = _DEFAULT_PAGE_SIZE
self._current_page = 0
self._last_events: List[dict] = []
self._sorted_events: List[dict] = []
super().__init__(0, 7 if show_result else 6) super().__init__(0, 7 if show_result else 6)
self.setEditTriggers(QTableWidget.NoEditTriggers) self.setEditTriggers(QTableWidget.NoEditTriggers)
self.setSelectionBehavior(QTableWidget.SelectRows) self.setSelectionBehavior(QTableWidget.SelectRows)
@@ -98,13 +109,60 @@ class EventTable(QTableWidget):
cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")] cols += [tr("monitoring.col_detail_block") if not self._show_result else tr("monitoring.col_detail")]
self.setHorizontalHeaderLabels(cols) self.setHorizontalHeaderLabels(cols)
def page_size(self) -> int:
"""Số dòng đang hiển thị mỗi trang."""
return self._page_size
def page_count(self) -> int:
"""Tổng số trang với dữ liệu và số dòng/trang hiện tại (tối thiểu 1)."""
if not self._sorted_events:
return 1
return -(-len(self._sorted_events) // self._page_size) # ceil div
def current_page(self) -> int:
"""Trang đang hiển thị, đánh số từ 0."""
return self._current_page
def go_to_page(self, page: int) -> None:
"""Nhảy tới một trang cụ thể (đánh số từ 0), tự kẹp trong khoảng hợp lệ."""
self._current_page = page
self._render_current_page()
def next_page(self) -> None:
"""Sang trang kế — không làm gì nếu đã ở trang cuối."""
self.go_to_page(self._current_page + 1)
def prev_page(self) -> None:
"""Về trang trước — không làm gì nếu đã ở trang đầu."""
self.go_to_page(self._current_page - 1)
def set_page_size(self, n: int) -> None:
"""Đổi số dòng hiển thị mỗi trang, quay về trang đầu, rồi vẽ lại với dữ
liệu đã có sẵn (không cần refresh lại từ nguồn)."""
self._page_size = n
self._current_page = 0
self._render_current_page()
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, cắt ở ``_MAX_ROWS``. """Đổ danh sách sự kiện vào bảng: mới nhất lên đầu, chia trang theo
``self._page_size`` — xem qua trang khác bằng ``next_page``/``prev_page``
(nút tiến/lùi ở filter_scaffold.py), không còn bị cắt bỏ vĩnh viễn như
trước (DF-006)."""
self._last_events = events
self._sorted_events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)
self._current_page = 0
self._render_current_page()
def _render_current_page(self) -> None:
"""Vẽ đúng một trang (theo ``self._current_page``/``self._page_size``)
từ ``self._sorted_events`` đã sắp sẵn.
Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc Tắt sắp xếp trong lúc đổ dữ liệu — để bật, Qt sắp lại sau mỗi dòng và việc
nạp chậm đi theo bậc hai. nạp chậm đi theo bậc hai.
""" """
events = sorted(events, key=lambda e: e.get("ts", ""), reverse=True)[:_MAX_ROWS] self._current_page = max(0, min(self._current_page, self.page_count() - 1))
start = self._current_page * self._page_size
events = self._sorted_events[start:start + self._page_size]
self.setSortingEnabled(False) self.setSortingEnabled(False)
self.setRowCount(len(events)) self.setRowCount(len(events))
for row, ev in enumerate(events): for row, ev in enumerate(events):
@@ -149,6 +207,7 @@ class EventTable(QTableWidget):
self.setItem(row, col, item) self.setItem(row, col, item)
self.setSortingEnabled(True) self.setSortingEnabled(True)
self.apply_filter(getattr(self, "_filter_needle", "")) self.apply_filter(getattr(self, "_filter_needle", ""))
self.page_changed.emit(self._current_page, self.page_count())
def apply_filter(self, needle: str) -> None: def apply_filter(self, needle: str) -> None:
"""Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường).""" """Ẩn/hiện dòng theo từ khoá tìm kiếm (không phân biệt hoa thường)."""
@@ -16,13 +16,13 @@ from typing import Callable, Dict, Optional
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from PySide6.QtGui import QKeySequence, QShortcut from PySide6.QtGui import QKeySequence, QShortcut
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QApplication, QHBoxLayout, QLabel, QLineEdit, QPushButton, QSplitter, QApplication, QComboBox, QHBoxLayout, QLabel, QLineEdit, QPushButton,
QTableWidget, QVBoxLayout, QWidget, QSplitter, QTableWidget, QVBoxLayout, QWidget,
) )
from ....i18n import tr from ....i18n import bind_tip, tr
from ....ui.icons import icon from ....ui.icons import icon
from .event_table import ClickOutsideCloser, EventTable from .event_table import PAGE_SIZE_OPTIONS, ClickOutsideCloser, EventTable
from .event_detail_panel import EventDetailPanel from .event_detail_panel import EventDetailPanel
@@ -47,11 +47,12 @@ def _sync_event_detail(table: EventTable, panel: EventDetailPanel) -> None:
def build_filter_scaffold( def build_filter_scaffold(
page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None], page: QWidget, table: QTableWidget, *, on_refresh: Callable[[], None],
title_key: Optional[str] = None, with_search: bool = True, title_key: Optional[str] = None, with_search: bool = True,
with_detail: bool = False, with_detail: bool = False, with_page_size: bool = False,
on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None, on_ai_filter: Optional[Callable[[QLineEdit, QPushButton], None]] = None,
) -> Dict[str, object]: ) -> Dict[str, object]:
"""Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm, """Dựng khung chung cho một tab sự kiện: tiêu đề, nút làm mới, ô tìm kiếm,
nút lọc bằng AI và panel chi tiết. nút lọc bằng AI, control "Số dòng/trang" (nếu ``with_page_size``) và panel
chi tiết.
Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ Bốn tab sự kiện của màn Giám sát chỉ khác nhau ở nguồn dữ liệu, nên phần vỏ
này được dựng một lần và dùng chung. này được dựng một lần và dùng chung.
@@ -82,12 +83,64 @@ def build_filter_scaffold(
search.textChanged.connect(table.apply_filter) search.textChanged.connect(table.apply_filter)
ai_btn = QPushButton(tr("monitoring.ai_filter_btn")) ai_btn = QPushButton(tr("monitoring.ai_filter_btn"))
ai_btn.setIcon(icon("sparkle")) ai_btn.setIcon(icon("sparkle"))
ai_btn.setToolTip(tr("monitoring.ai_filter_tooltip")) # Bound rather than set once: this scaffold builds the button for all
# three event tabs, and none of their retranslate() methods can reach a
# tooltip that was applied here.
bind_tip(ai_btn, "monitoring.ai_filter_tooltip")
ai_btn.setCursor(Qt.PointingHandCursor) ai_btn.setCursor(Qt.PointingHandCursor)
if on_ai_filter is not None: if on_ai_filter is not None:
ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn)) ai_btn.clicked.connect(lambda: on_ai_filter(search, ai_btn))
row.addWidget(search, 1) row.addWidget(search, 1)
row.addWidget(ai_btn) row.addWidget(ai_btn)
if with_page_size and isinstance(table, EventTable):
# DF-006: the item-per-page count was never surfaced anywhere in
# the UI (design called for it) — EventTable already trims to a
# page size internally, this just makes that number visible AND
# user-choosable instead of a fixed constant.
page_size_lbl = QLabel(tr("monitoring.page_size_label"))
page_size_combo = QComboBox()
for n in PAGE_SIZE_OPTIONS:
page_size_combo.addItem(str(n), n)
current = table.page_size()
page_size_combo.setCurrentIndex(
PAGE_SIZE_OPTIONS.index(current) if current in PAGE_SIZE_OPTIONS else 0)
page_size_combo.currentIndexChanged.connect(
lambda i: table.set_page_size(page_size_combo.itemData(i)))
row.addWidget(page_size_lbl)
row.addWidget(page_size_combo)
# DF-006 follow-up: trimming to a page size alone silently dropped
# every row past it with no way back to see them — prev/next
# buttons plus a "trang X/Y" indicator make the rest reachable.
page_prev_btn = QPushButton()
page_prev_btn.setIcon(icon("chevron-left"))
page_prev_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_prev_btn, "monitoring.page_prev")
page_next_btn = QPushButton()
page_next_btn.setIcon(icon("chevron-right"))
page_next_btn.setCursor(Qt.PointingHandCursor)
bind_tip(page_next_btn, "monitoring.page_next")
page_indicator_lbl = QLabel()
def _refresh_pager(cur: int = None, total: int = None) -> None:
if cur is None or total is None:
cur, total = table.current_page(), table.page_count()
page_indicator_lbl.setText(tr("monitoring.page_indicator", page=cur + 1, total=total))
page_prev_btn.setEnabled(cur > 0)
page_next_btn.setEnabled(cur < total - 1)
page_prev_btn.clicked.connect(table.prev_page)
page_next_btn.clicked.connect(table.next_page)
table.page_changed.connect(_refresh_pager)
_refresh_pager()
row.addWidget(page_prev_btn)
row.addWidget(page_indicator_lbl)
row.addWidget(page_next_btn)
parts.update(
page_size_label=page_size_lbl, page_size_combo=page_size_combo,
page_prev_btn=page_prev_btn, page_next_btn=page_next_btn,
page_indicator_label=page_indicator_lbl, page_pager_refresh=_refresh_pager)
lay.addLayout(row) lay.addLayout(row)
parts.update(filter_edit=search, ai_filter_btn=ai_btn) parts.update(filter_edit=search, ai_filter_btn=ai_btn)
@@ -25,13 +25,16 @@ class ActionLogsTab(QWidget):
parts = build_filter_scaffold( parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all, self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.action_logs_title", title_key="monitoring.action_logs_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) with_search=True, with_detail=True, with_page_size=True,
on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"] self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"] self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"] self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"] self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +47,8 @@ class ActionLogsTab(QWidget):
self.detail_panel.retranslate() self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -20,6 +20,7 @@ from ....core import admin_agents, preview_ai
from ....core.worker import AgentWorker from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr from ....i18n import on_language_changed, tr
from ....state import AppContext from ....state import AppContext
from ....ui.dialog_buttons import dialog_buttons
from ....ui.icons import icon from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget from ....ui.widgets import ToggleSwitch, badge_pill_widget
@@ -95,7 +96,7 @@ class AgentEditDialog(QDialog):
self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled")) self.enabled_chk = QCheckBox(tr("agents_admin.f_enabled"))
self.enabled_chk.setChecked(agent.enabled if agent else True) self.enabled_chk.setChecked(agent.enabled if agent else True)
form.addRow("", self.enabled_chk) form.addRow("", self.enabled_chk)
buttons = QDialogButtonBox(QDialogButtonBox.Save | QDialogButtonBox.Cancel) buttons = dialog_buttons(QDialogButtonBox.Save | QDialogButtonBox.Cancel)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
form.addRow(buttons) form.addRow(buttons)
@@ -26,7 +26,7 @@ from typing import Dict, List
from PySide6.QtCore import QSize, Qt from PySide6.QtCore import QSize, Qt
from PySide6.QtWidgets import ( from PySide6.QtWidgets import (
QHBoxLayout, QHeaderView, QLabel, QMessageBox, QPushButton, QHBoxLayout, QHeaderView, QLabel, QPushButton,
QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget, QTableWidget, QTableWidgetItem, QVBoxLayout, QWidget,
) )
@@ -35,6 +35,7 @@ from ....core import admin_agents
from ....core.worker import AgentWorker from ....core.worker import AgentWorker
from ....i18n import on_language_changed, tr from ....i18n import on_language_changed, tr
from ....state import AppContext from ....state import AppContext
from ....ui.dialog_buttons import confirm
from ....ui.icons import icon from ....ui.icons import icon
from ....ui.widgets import ToggleSwitch, badge_pill_widget from ....ui.widgets import ToggleSwitch, badge_pill_widget
from .agent_edit_dialog import AgentEditDialog from .agent_edit_dialog import AgentEditDialog
@@ -189,9 +190,8 @@ class AgentsAdminTab(QWidget):
agent = admin_agents.load_agent(agent_id, self._dir()) agent = admin_agents.load_agent(agent_id, self._dir())
if agent is None: if agent is None:
return return
if QMessageBox.question( if not confirm(self, tr("agents_admin.delete_title"),
self, tr("agents_admin.delete_title"), tr("agents_admin.delete_confirm", name=agent.name)):
tr("agents_admin.delete_confirm", name=agent.name)) != QMessageBox.Yes:
return return
admin_agents.delete_agent(agent.agent_id, self._dir()) admin_agents.delete_agent(agent.agent_id, self._dir())
self.refresh() self.refresh()
+6 -1
View File
@@ -25,13 +25,16 @@ class McpTab(QWidget):
parts = build_filter_scaffold( parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all, self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.mcp_history_title", title_key="monitoring.mcp_history_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) with_search=True, with_detail=True, with_page_size=True,
on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"] self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"] self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"] self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"] self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -44,6 +47,8 @@ class McpTab(QWidget):
self.detail_panel.retranslate() self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -139,11 +139,11 @@ class PricingPanel(QGroupBox):
def _add_pricing_row(self) -> None: def _add_pricing_row(self) -> None:
"""Thêm một dòng đơn giá trống để người dùng điền tay.""" """Thêm một dòng đơn giá trống để người dùng điền tay."""
from PySide6.QtWidgets import QInputDialog from ....ui.dialog_buttons import ask_text
from ....core import model_pricing as mp from ....core import model_pricing as mp
name, ok = QInputDialog.getText(self, tr("monitoring.pricing_add"), name, ok = ask_text(self, tr("monitoring.pricing_add"),
tr("monitoring.pricing_add_prompt")) tr("monitoring.pricing_add_prompt"))
name = (name or "").strip() name = (name or "").strip()
if not ok or not name: if not ok or not name:
return return
@@ -31,13 +31,16 @@ class SecurityEventsTab(QWidget):
parts = build_filter_scaffold( parts = build_filter_scaffold(
self, self.table, on_refresh=on_refresh_all, self, self.table, on_refresh=on_refresh_all,
title_key="monitoring.security_events_title", title_key="monitoring.security_events_title",
with_search=True, with_detail=True, on_ai_filter=self._start_ai_filter) with_search=True, with_detail=True, with_page_size=True,
on_ai_filter=self._start_ai_filter)
self.title_lbl = parts["title_lbl"] self.title_lbl = parts["title_lbl"]
self.title_key = parts["title_key"] self.title_key = parts["title_key"]
self.title_refresh_btn = parts["title_refresh_btn"] self.title_refresh_btn = parts["title_refresh_btn"]
self.filter_edit = parts["filter_edit"] self.filter_edit = parts["filter_edit"]
self.ai_filter_btn = parts["ai_filter_btn"] self.ai_filter_btn = parts["ai_filter_btn"]
self.detail_panel = parts["detail_panel"] self.detail_panel = parts["detail_panel"]
self.page_size_label = parts["page_size_label"]
self.page_pager_refresh = parts["page_pager_refresh"]
def set_events(self, events: List[dict]) -> None: def set_events(self, events: List[dict]) -> None:
"""Đổ danh sách sự kiện vào bảng.""" """Đổ danh sách sự kiện vào bảng."""
@@ -50,6 +53,8 @@ class SecurityEventsTab(QWidget):
self.detail_panel.retranslate() self.detail_panel.retranslate()
self.title_lbl.setText(tr(self.title_key)) self.title_lbl.setText(tr(self.title_key))
self.title_refresh_btn.setText(tr("monitoring.refresh")) self.title_refresh_btn.setText(tr("monitoring.refresh"))
self.page_size_label.setText(tr("monitoring.page_size_label"))
self.page_pager_refresh()
def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None: def _start_ai_filter(self, search: QLineEdit, ai_btn: QPushButton) -> None:
"""Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc.""" """Nhờ AI dịch câu tìm kiếm tự nhiên thành từ khoá lọc."""
@@ -179,9 +179,12 @@ class ToolsAdminTab(QWidget):
hdr.addWidget(sw) hdr.addWidget(sw)
lay.addLayout(hdr) lay.addLayout(hdr)
desc = QLabel(spec.description) # spec.description là mô tả gửi cho mô hình (schema function-calling),
# luôn tiếng Anh và viết cho máy đọc — thẻ này dùng bản dịch riêng.
desc_text = tr(f"tools_admin.desc.{spec.name}")
desc = QLabel(desc_text)
desc.setWordWrap(True) desc.setWordWrap(True)
desc.setToolTip(spec.description) desc.setToolTip(desc_text)
desc.setObjectName("hint") desc.setObjectName("hint")
desc.setStyleSheet("border: none;") desc.setStyleSheet("border: none;")
lay.addWidget(desc) lay.addWidget(desc)
@@ -32,6 +32,7 @@ from cowork_local.core.worker import AgentWorker
from cowork_local.i18n import tr from cowork_local.i18n import tr
from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel from cowork_local.presentation.scheduling.ai_task_import_dialog import ImportTaskPanel
from cowork_local.state import AppContext from cowork_local.state import AppContext
from cowork_local.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.icons import icon from cowork_local.ui.icons import icon
@@ -79,8 +80,8 @@ class AiTaskCreatorDialog(QDialog):
self.import_panel.tasks_changed.connect(self._on_import_tasks_changed) self.import_panel.tasks_changed.connect(self._on_import_tasks_changed)
self.tabs.addTab(self.import_panel, tr("schedtask.tab_import")) self.tabs.addTab(self.import_panel, tr("schedtask.tab_import"))
self.buttons = QDialogButtonBox(QDialogButtonBox.Ok | QDialogButtonBox.Cancel) self.buttons = dialog_buttons(QDialogButtonBox.Ok | QDialogButtonBox.Cancel,
self.buttons.button(QDialogButtonBox.Ok).setText(tr("schedtask.ai_confirm")) ok="schedtask.ai_confirm")
self.buttons.button(QDialogButtonBox.Ok).setEnabled(False) self.buttons.button(QDialogButtonBox.Ok).setEnabled(False)
self.buttons.accepted.connect(self._confirm) self.buttons.accepted.connect(self._confirm)
self.buttons.rejected.connect(self.reject) self.buttons.rejected.connect(self.reject)
@@ -38,6 +38,7 @@ from cowork_local.infrastructure.persistence.json.task_repository_impl import (
) )
from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog from cowork_local.presentation.scheduling.run_history_dialog import RunHistoryDialog
from cowork_local.theme import current_palette from cowork_local.theme import current_palette
from cowork_local.ui.dialog_buttons import confirm
from cowork_local.ui.osutil import open_path from cowork_local.ui.osutil import open_path
# Priority shown as a plain text tag (no colored-emoji squares). Only the # Priority shown as a plain text tag (no colored-emoji squares). Only the
@@ -317,9 +318,8 @@ class KanbanBoardWidget(QWidget):
elif chosen == next_act: elif chosen == next_act:
self._create_next_from_output(task) self._create_next_from_output(task)
elif chosen == del_act: elif chosen == del_act:
if QMessageBox.question(self, tr("schedtask.menu_delete"), if confirm(self, tr("schedtask.menu_delete"),
tr("schedtask.delete_confirm", title=task.get("title", "")) tr("schedtask.delete_confirm", title=task.get("title", ""))):
) == QMessageBox.Yes:
self._service.delete(tid) self._service.delete(tid)
self.refresh() self.refresh()
@@ -335,9 +335,8 @@ class KanbanBoardWidget(QWidget):
"""Confirm, then delete every task in ``selected``. Split out of """Confirm, then delete every task in ``selected``. Split out of
_bulk_delete_menu so tests can drive it directly without having to _bulk_delete_menu so tests can drive it directly without having to
fake a real (modal, event-loop-blocking) QMenu popup.""" fake a real (modal, event-loop-blocking) QMenu popup."""
if QMessageBox.question( if not confirm(self, tr("schedtask.menu_delete"),
self, tr("schedtask.menu_delete"), tr("schedtask.delete_multi_confirm", n=len(selected))):
tr("schedtask.delete_multi_confirm", n=len(selected))) != QMessageBox.Yes:
return False return False
ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)] ids = [it.data(Qt.UserRole) for it in selected if it.data(Qt.UserRole)]
self._service.bulk_delete(ids) self._service.bulk_delete(ids)
@@ -381,7 +380,7 @@ class KanbanBoardWidget(QWidget):
nxt["dependency"]["previous_task_id"] = task["task_id"] nxt["dependency"]["previous_task_id"] = task["task_id"]
err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"]) err = chain_error(self._repo.list() + [nxt], task["task_id"], nxt["task_id"])
if err: if err:
QMessageBox.warning(self, tr("schedtask.g_dependency"), err) QMessageBox.warning(self, tr("schedtask.g_dependency"), tr(err))
return return
self._repo.save(nxt) self._repo.save(nxt)
task["dependency"]["next_task_id"] = nxt["task_id"] task["dependency"]["next_task_id"] = nxt["task_id"]
@@ -10,6 +10,7 @@ from PySide6.QtWidgets import (
) )
from cowork_local.i18n import tr from cowork_local.i18n import tr
from cowork_local.ui.dialog_buttons import dialog_buttons
from cowork_local.ui.osutil import open_path from cowork_local.ui.osutil import open_path
@@ -53,7 +54,7 @@ class RunHistoryDialog(QDialog):
self.table.itemDoubleClicked.connect(self._open_artifact) self.table.itemDoubleClicked.connect(self._open_artifact)
root.addWidget(self.table, 1) root.addWidget(self.table, 1)
buttons = QDialogButtonBox(QDialogButtonBox.Close) buttons = dialog_buttons(QDialogButtonBox.Close)
buttons.rejected.connect(self.reject) buttons.rejected.connect(self.reject)
buttons.accepted.connect(self.accept) buttons.accepted.connect(self.accept)
root.addWidget(buttons) root.addWidget(buttons)
+4 -4
View File
@@ -43,10 +43,10 @@ class AboutSettingsWidget(QWidget):
self.app_label.setFont(font) self.app_label.setFont(font)
layout.addWidget(self.app_label) layout.addWidget(self.app_label)
self.credit_label = QLabel(tr("app.credit")) self.version_label = QLabel(tr("app.version", v=__version__))
self.credit_label.setObjectName("faint") self.version_label.setObjectName("faint")
self.credit_label.setTextInteractionFlags(Qt.TextSelectableByMouse) self.version_label.setTextInteractionFlags(Qt.TextSelectableByMouse)
layout.addWidget(self.credit_label) layout.addWidget(self.version_label)
layout.addStretch(1) layout.addStretch(1)
+108
View File
@@ -0,0 +1,108 @@
"""Lớp phủ "đang xử lý" ở cấp cửa sổ, dành cho tác vụ chặn GUI thread.
Vì sao là file riêng chứ không nhét vào ``main_window.py``: file đó chỉ còn 9
dòng vật lý dưới trần 400 của Gate S, và một lớp phủ cấp cửa sổ là một trách
nhiệm riêng (guardrail G6).
Cùng lý do ``repaint()`` với panel bận của GraphRAG — xem
``presentation/graph/structure_graph_view.py:139-151``.
"""
from __future__ import annotations
from time import perf_counter
from typing import Callable
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
# Dưới mức này người dùng chưa kịp nhận ra mình đang đợi, nên một lớp phủ toàn
# cửa sổ chỉ kịp nháy lên rồi tắt — tự nó là một khuyết tật giao diện, không
# phải một lời trấn an.
_NOTICEABLE_MS = 400.0
class BusyOverlay(QWidget):
"""A window-wide "please wait" cover for work that blocks the GUI thread.
Deliberately NOT registered with :func:`i18n.on_language_changed`: the text
is supplied by the caller right before the block and must stay in the
language the rest of the screen is still showing.
"""
def __init__(self, parent: QWidget) -> None:
"""Build the cover hidden; it sizes itself to the parent on every show."""
super().__init__(parent)
self.setObjectName("busyOverlay")
# A QWidget SUBCLASS ignores a stylesheet background without this
# attribute; a plain QWidget instance (the panel below) does not need it.
self.setAttribute(Qt.WA_StyledBackground, True)
self.setFocusPolicy(Qt.NoFocus)
# Chưa đo được lượt nào: xem mục ``run_blocking``.
self._last_ms: float | None = None
lay = QVBoxLayout(self)
lay.setContentsMargins(0, 0, 0, 0)
lay.addStretch(1)
row = QHBoxLayout()
row.addStretch(1)
self._panel = QWidget()
self._panel.setObjectName("busyOverlayPanel")
inner = QHBoxLayout(self._panel)
inner.setContentsMargins(24, 18, 24, 18)
self._label = QLabel()
inner.addWidget(self._label)
row.addWidget(self._panel)
row.addStretch(1)
lay.addLayout(row)
lay.addStretch(1)
self.hide()
def text(self) -> str:
"""Chữ đang hiện trên lớp phủ (dùng cho test)."""
return self._label.text()
def run_blocking(self, message: str, work: Callable[[], None]) -> None:
"""Run ``work`` on the GUI thread, covered only when that is worth doing.
Nothing can time the freeze WHILE it happens: the GUI thread stops, so
no timer fires and no watchdog can raise the cover mid-way. The only
honest clock is the PREVIOUS run of this same call, so that is what
decides. No measurement yet (the first switch of a process) errs
towards showing: one flash is a smaller defect than a multi-second
freeze with nothing on screen to explain it.
The result is self-calibrating. A fast machine flashes once per launch
and then stays out of the way; a slow one, or a big skill library, gets
the cover on every switch from the second one on.
``work`` is timed and its exceptions propagate — the cover still comes
down, so a raising callback cannot leave it stuck on screen forever.
"""
if self._last_ms is None or self._last_ms >= _NOTICEABLE_MS:
self.show_busy(message)
started = perf_counter()
try:
work()
finally:
self._last_ms = (perf_counter() - started) * 1000.0
self.hide_busy()
def show_busy(self, message: str) -> None:
"""Show the cover and FORCE it onto the screen right now.
``repaint()``, not ``update()``: the caller is about to block the GUI
thread, so a queued paint would only run once the freeze is over — the
one moment the cover is no longer needed.
No animated progress bar on purpose: with no event loop running,
nothing would move; only static text is guaranteed to be readable.
"""
self._label.setText(message)
self.setGeometry(self.parent().rect())
self.show()
self.raise_()
self.repaint()
def hide_busy(self) -> None:
"""Release the cover. Call from ``finally`` so a raising callback
cannot leave it stuck on screen forever."""
self.hide()
+22 -4
View File
@@ -177,10 +177,10 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self.help_agent.status_message.connect(self.statusBar().showMessage) self.help_agent.status_message.connect(self.statusBar().showMessage)
self.statusBar().showMessage(tr("app.status.ready")) self.statusBar().showMessage(tr("app.status.ready"))
# Dòng ghi công tác giả đã chuyển vào Cài đặt ▸ Giới thiệu # Góc dưới phải: đúng một dòng phiên bản (cùng nguồn với tiêu đề cửa sổ).
# (presentation/settings/about_widget.py). Nó từng là widget thường trực # Tắt size grip — nó vẽ một vệt ngay bên phải chữ; cửa sổ vẫn kéo được cạnh.
# ở góc dưới phải: chiếm một góc màn hình trên MỌI màn hình, suốt cả self.statusBar().setSizeGripEnabled(False)
# phiên, cho một thông tin chỉ cần đọc một lần. self.statusBar().addPermanentWidget(QLabel(tr("app.version", v=__version__)))
self._restore_sessions() self._restore_sessions()
# Open on "All projects…" — literally the same call the nav rail's link # Open on "All projects…" — literally the same call the nav rail's link
# of that name makes, so the rail highlight and the content can never # of that name makes, so the rail highlight and the content can never
@@ -218,6 +218,11 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width()) self._set_nav_width_range(_NAV_MIN_WIDTH, self._nav_max_width())
# Keep the floating Help assistant pinned to the bottom-right corner. # Keep the floating Help assistant pinned to the bottom-right corner.
if getattr(self, "help_agent", None) is not None: if getattr(self, "help_agent", None) is not None:
# The Cowork composer can wrap an extra control row as the window
# narrows/widens, which changes how much bottom guard the dock
# needs — recompute it on every resize, not just reposition with
# whatever guard height was last measured at tab-entry time.
self._update_dock_guard()
self.help_agent.reposition() self.help_agent.reposition()
def showEvent(self, event): # noqa: N802 - Qt override def showEvent(self, event): # noqa: N802 - Qt override
@@ -255,6 +260,12 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip")) tr("app.nav.expand_tooltip") if self._nav_collapsed else tr("app.nav.collapse_tooltip"))
if hasattr(self, "provider_lbl"): if hasattr(self, "provider_lbl"):
self.provider_lbl.setText(tr("app.provider")) self.provider_lbl.setText(tr("app.provider"))
# The label is hidden — the combo names itself through its tooltip
# (see top_bar._build_account_row), so that is the one users read.
self.provider_combo.setToolTip(tr("app.provider"))
if hasattr(self, "nav_project"):
self.nav_project.setToolTip(tr("app.nav.project_pick"))
self.nav_recents_hdr.setText(tr("app.nav.recents"))
if hasattr(self, "settings_btn"): if hasattr(self, "settings_btn"):
self.settings_btn.setText(tr("app.settings")) self.settings_btn.setText(tr("app.settings"))
if hasattr(self, "theme_btn"): if hasattr(self, "theme_btn"):
@@ -266,6 +277,13 @@ class MainWindow(NavRailMixin, RailProjectMixin, TopBarMixin,
if getattr(self, "help_agent", None) is not None: if getattr(self, "help_agent", None) is not None:
self.help_agent.retranslate() self.help_agent.retranslate()
self._tray.retranslate() self._tray.retranslate()
# Thanh trạng thái (góc dưới bên trái) nhận thông báo từ hàng chục nơi
# qua signal ``status_message``, và signal đó mang CHUỖI ĐÃ DỊCH chứ
# không mang khoá — nên không thể dịch lại câu đang hiện. Đưa nó về câu
# nền của ngôn ngữ mới: câu cũ không đọng lại bằng thứ tiếng vừa rời đi,
# mà chỗ đó cũng không trống trơn. Thông báo là ghi chú về một việc vừa
# xong, nên bỏ nó đi khi đổi ngôn ngữ không làm mất thông tin nào.
self.statusBar().showMessage(tr("app.status.ready"))
# ---- system tray (run in background when the window is closed) --- # ---- system tray (run in background when the window is closed) ---
+21 -16
View File
@@ -81,7 +81,7 @@ class NavRailMixin:
# 16px icon up with the nav items' icons below (1px list frame + item # 16px icon up with the nav items' icons below (1px list frame + item
# padding) — same indent level, same icon size as e.g. Dashboard. # padding) — same indent level, same icon size as e.g. Dashboard.
toggle_row = QHBoxLayout() toggle_row = QHBoxLayout()
toggle_row.setContentsMargins(0, 8, 10, 8) toggle_row.setContentsMargins(10, 8, 10, 8)
toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft) toggle_row.addWidget(self._nav_toggle_btn, 0, Qt.AlignLeft)
toggle_row.addStretch(1) toggle_row.addStretch(1)
nvl.addLayout(toggle_row) nvl.addLayout(toggle_row)
@@ -111,7 +111,7 @@ class NavRailMixin:
self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu) self.nav_project_btn.menu().aboutToShow.connect(self._fill_rail_project_menu)
self.nav_project_btn.setVisible(False) self.nav_project_btn.setVisible(False)
head = QVBoxLayout() head = QVBoxLayout()
head.setContentsMargins(6, 0, 6, 6) head.setContentsMargins(10, 0, 10, 6)
head.setSpacing(6) head.setSpacing(6)
head.addWidget(self.nav_project) head.addWidget(self.nav_project)
head.addWidget(self.nav_project_btn) head.addWidget(self.nav_project_btn)
@@ -136,7 +136,7 @@ class NavRailMixin:
self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff) self._nav_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
scroll_body = QWidget() scroll_body = QWidget()
sv = QVBoxLayout(scroll_body) sv = QVBoxLayout(scroll_body)
sv.setContentsMargins(0, 0, 0, 0) sv.setContentsMargins(6, 0, 6, 0)
sv.setSpacing(0) sv.setSpacing(0)
sv.addWidget(self.nav, 0) sv.addWidget(self.nav, 0)
# RECENTS — the threads of the project named in the picker above, right # RECENTS — the threads of the project named in the picker above, right
@@ -197,9 +197,9 @@ class NavRailMixin:
def _nav_rows(self): def _nav_rows(self):
"""(tree, page, sub, label, icon, enabled) for every row, rail order. """(tree, page, sub, label, icon, enabled) for every row, rail order.
Workspace contributes all five of its sub-views — including the two the Workspace contributes all five of its sub-views; ``_rebuild_nav`` bỏ
project gate currently disables — so the rail never changes shape while những hàng mà cổng project đang đóng (Cowork, GraphRAG) thay vì hiện
the user is looking at it. chúng ở dạng mờ.
""" """
rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on) rows = [(self.nav, self._ROW_WORKSPACE, sub, label, ic, on)
for label, sub, ic, on in self.workspace.nav_entries()] for label, sub, ic, on in self.workspace.nav_entries()]
@@ -238,25 +238,30 @@ class NavRailMixin:
tree.clear() tree.clear()
tree.blockSignals(blocked) tree.blockSignals(blocked)
for tree, page, sub, label, icon_name, enabled in spec: for tree, page, sub, label, icon_name, enabled in spec:
if not enabled:
# Cổng project đóng → bỏ hẳn hàng, không hiện dạng mờ nữa.
continue
it = QTreeWidgetItem([""] if self._nav_collapsed else [label]) it = QTreeWidgetItem([""] if self._nav_collapsed else [label])
it.setIcon(0, _icon(icon_name)) it.setIcon(0, _icon(icon_name))
it.setData(0, Qt.UserRole, {"page": page, "sub": sub}) it.setData(0, Qt.UserRole, {"page": page, "sub": sub})
if not enabled: if self._nav_collapsed:
# Same gate as before, shown instead of hidden: the row stays
# in place, greyed, and says why it cannot be opened.
it.setDisabled(True)
it.setToolTip(0, tr("app.nav.needs_project"))
elif self._nav_collapsed:
it.setToolTip(0, label) it.setToolTip(0, label)
blocked = tree.blockSignals(True) blocked = tree.blockSignals(True)
tree.addTopLevelItem(it) tree.addTopLevelItem(it)
tree.blockSignals(blocked) tree.blockSignals(blocked)
# Both destination lists are exactly as tall as their rows; the # Rows plus frame, nothing else: the flat ``+ 8`` this replaces
# stretch in between belongs to RECENTS. # left 6px of dead space under the last row, and since Settings
# sits directly under nav_bottom it fell between Giám sát and
# Settings alone — 18/26px for rows that read as one list.
row_h = 0
for tree in (self.nav, self.nav_bottom): for tree in (self.nav, self.nav_bottom):
n = tree.topLevelItemCount() n = tree.topLevelItemCount()
row_h = tree.sizeHintForRow(0) if n else 0 row_h = tree.sizeHintForRow(0) if n else row_h
tree.setFixedHeight(n * row_h + 8) tree.setFixedHeight(n * row_h + 2 * tree.frameWidth())
# 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: if keep:
self._select_nav_row(*keep) self._select_nav_row(*keep)
finally: finally:
+8 -1
View File
@@ -13,8 +13,15 @@ from PySide6.QtWidgets import QStyledItemDelegate
# ---- kích thước --------------------------------------------------------- # ---- kích thước ---------------------------------------------------------
_NAV_EXPANDED_WIDTH = 232 _NAV_EXPANDED_WIDTH = 232
_NAV_COLLAPSED_WIDTH = 54 _NAV_COLLAPSED_WIDTH = 54
_NAV_ROW_INSET = 4 _NAV_ROW_INSET = 8
_NAV_ROW_GAP = 6 _NAV_ROW_GAP = 6
# Khe TRÊN nút Cài đặt, tính bằng khoảng trống thật trong layout của rail.
# Không đặt bằng ``margin`` trong QSS: margin của stylesheet được vẽ BÊN TRONG
# hộp của widget, mà nút này lại bị ``_rebuild_nav`` ghim đúng chiều cao một
# dòng menu — nên margin không mua được một pixel khoảng cách nào.
# 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 # 132 -> 232: o 132px nhan "Cuoc tro chuyen moi" bi cat mat chu. San phai du
# rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh. # rong cho nhan DAI NHAT tren thanh, khong phai cho nhan trung binh.
_NAV_MIN_WIDTH = 232 _NAV_MIN_WIDTH = 232
+6
View File
@@ -42,6 +42,12 @@ class RailProjectMixin:
# No project yet: say so, and say what to do about it, instead of # No project yet: say so, and say what to do about it, instead of
# leaving an empty box and a button that silently does nothing. # leaving an empty box and a button that silently does nothing.
self.nav_project.addItem(tr("app.nav.no_project"), "") self.nav_project.addItem(tr("app.nav.no_project"), "")
elif not current:
# Có project nhưng CHƯA chọn cái nào (mở app lên, hoặc vừa xoá
# project đang mở). Không có mục này thì combo rơi về mục 0 và
# chỉ bừa vào project đầu danh sách, trong khi cổng
# Cowork/GraphRAG vẫn đóng — hai chỗ nói hai đằng.
self.nav_project.insertItem(0, tr("app.nav.pick_project"), "")
idx = self.nav_project.findData(current) idx = self.nav_project.findData(current)
if idx >= 0: if idx >= 0:
self.nav_project.setCurrentIndex(idx) self.nav_project.setCurrentIndex(idx)
+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.""" """Project được tạo/sửa/xoá: gom nhóm lại cột lịch sử và cập nhật nhãn thư mục."""
self.sidebar.refresh() # History regroups by project self.sidebar.refresh() # History regroups by project
self.cowork._apply_output_folder_label() # project may have been renamed self.cowork._apply_output_folder_label() # project may have been renamed
self.structure._refresh_project_combo() # GraphRAG's project lock list follows too self.structure.refresh_project_list() # GraphRAG's project lock list follows too
+54 -4
View File
@@ -35,7 +35,7 @@ class TopBarMixin:
from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton from PySide6.QtWidgets import QHBoxLayout, QLabel, QPushButton
from ...i18n import tr from ...i18n import tr
from ...ui.icons import icon as _icon from ...ui.icons import icon as _icon
from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET from .rail_metrics import _NAV_ROW_GAP, _NAV_ROW_INSET, _NAV_SETTINGS_GAP
# Bottom-pinned group: the places you visit occasionally, kept out of the # Bottom-pinned group: the places you visit occasionally, kept out of the
# way of the ones you live in. A hairline (styled via #navrailBottom in # way of the ones you live in. A hairline (styled via #navrailBottom in
@@ -54,7 +54,12 @@ class TopBarMixin:
self._nav_settings_btn.setCursor(Qt.PointingHandCursor) self._nav_settings_btn.setCursor(Qt.PointingHandCursor)
self._nav_settings_btn.clicked.connect(self._open_settings) self._nav_settings_btn.clicked.connect(self._open_settings)
srow = QHBoxLayout(self._nav_settings_btn) srow = QHBoxLayout(self._nav_settings_btn)
srow.setContentsMargins(_NAV_ROW_INSET, 6, 8, 6) # No vertical padding of its own: ``_rebuild_nav`` pins this button to the
# nav rows' OWN height, so the 6px a row pads with is already inside
# that number. Adding it again here made the row taller than the button
# (28 wanted, 20 given), which both clipped the icon and pushed the text
# 8px below an even pitch with Dashboard / Giám sát.
srow.setContentsMargins(_NAV_ROW_INSET, 0, 8, 0)
srow.setSpacing(_NAV_ROW_GAP) srow.setSpacing(_NAV_ROW_GAP)
self._nav_settings_icon = QLabel() self._nav_settings_icon = QLabel()
self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16)) self._nav_settings_icon.setPixmap(_icon("settings").pixmap(16, 16))
@@ -63,6 +68,15 @@ class TopBarMixin:
srow.addWidget(self._nav_settings_icon) srow.addWidget(self._nav_settings_icon)
srow.addWidget(self._nav_settings_text) srow.addWidget(self._nav_settings_text)
srow.addStretch(1) srow.addStretch(1)
# 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) nvl.addWidget(self._nav_settings_btn)
self._account_row = self._build_account_row() self._account_row = self._build_account_row()
@@ -194,6 +208,39 @@ class TopBarMixin:
# Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon # Khong bao "dang dung <provider>" o thanh trang thai: chinh bo chon
# provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong # provider nam ngay tren man hinh va da hien thu vua chon, nen dong thong
# bao chi nhac lai mot thu nguoi dung vua tu tay lam. # bao chi nhac lai mot thu nguoi dung vua tu tay lam.
def _lang_busy_overlay(self):
"""The window's busy cover, built on first use.
Built lazily so a window that never changes language never gets one —
and so ``_open_settings`` can be checked for "no switch, no flash".
"""
overlay = getattr(self, "_lang_busy", None)
if overlay is None:
from .busy_overlay import BusyOverlay
overlay = BusyOverlay(self)
self._lang_busy = overlay
return overlay
def _switch_language(self, lang: str) -> None:
"""Apply a new UI language behind a busy cover.
``set_language`` runs every registered widget's re-translation on the
GUI thread, which on a large skill library takes long enough to look
like a hang. Nothing can raise a cover once that has started (no event
loop is left running), so it goes up FIRST — see ``busy_overlay.py``.
The message is read before the switch on purpose: mid-switch the only
language the user can still read is the one being left behind.
"""
message = tr("app.lang.switching")
self.language_combo.setEnabled(False)
try:
self._lang_busy_overlay().run_blocking(message, lambda: set_language(lang))
finally:
# In a ``finally`` so a listener that raises cannot leave the
# switcher locked for the rest of the session.
self.language_combo.setEnabled(True)
def _on_language_changed(self, _idx: int) -> None: def _on_language_changed(self, _idx: int) -> None:
"""Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại """Đổi ngôn ngữ giao diện; trùng ngôn ngữ hiện tại thì bỏ qua để không dựng lại
toàn bộ chữ vô ích. toàn bộ chữ vô ích.
@@ -203,7 +250,7 @@ class TopBarMixin:
return return
self.ctx.config.language = lang self.ctx.config.language = lang
self.ctx.save() self.ctx.save()
set_language(lang) # notifies every registered persistent widget self._switch_language(lang) # notifies every registered persistent widget
def _open_settings(self) -> None: def _open_settings(self) -> None:
"""Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên.""" """Mở hộp thoại Cài đặt; bấm Lưu thì áp lại theme và làm mới thanh trên."""
dlg = SettingsDialog(self.ctx, self) dlg = SettingsDialog(self.ctx, self)
@@ -214,7 +261,10 @@ class TopBarMixin:
from ...ui.icons import icon as _theme_icon from ...ui.icons import icon as _theme_icon
self.theme_btn.setIcon( self.theme_btn.setIcon(
_theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor"))) _theme_icon(self._THEME_ICONS.get(self.ctx.config.theme, "monitor")))
set_language(self.ctx.config.language) # apply if changed in Settings # Guarded, not left to set_language's own no-op check: the cover
# around the switch would otherwise flash on every Save.
if self.ctx.config.language != get_language():
self._switch_language(self.ctx.config.language)
# reflect provider/theme/language changes # reflect provider/theme/language changes
i = self.provider_combo.findData(self.ctx.config.active_provider) i = self.provider_combo.findData(self.ctx.config.active_provider)
if i >= 0: if i >= 0:
+3 -3
View File
@@ -28,6 +28,7 @@ from PySide6.QtWidgets import (
) )
from ...i18n import tr from ...i18n import tr
from ...ui.dialog_buttons import confirm
#: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``. #: Ten project mac dinh. Co y KHONG dich — xem ghi chu trong ``_create``.
_DEFAULT_PROJECT_NAME = "Project" _DEFAULT_PROJECT_NAME = "Project"
@@ -292,9 +293,8 @@ class ProjectEditingMixin:
project = load_project(pid) if pid else None project = load_project(pid) if pid else None
if project is None: if project is None:
return return
if QMessageBox.question( if not confirm(self, tr("workspace.delete"),
self, tr("workspace.delete"), tr("workspace.delete_confirm", name=project.name)):
tr("workspace.delete_confirm", name=project.name)) != QMessageBox.Yes:
return return
delete_project(pid) delete_project(pid)
self._current_id = "" self._current_id = ""
+22
View File
@@ -19,6 +19,14 @@ set "APPHOME=%LOCALAPPDATA%\CoworkLocal"
set "VENV=%APPHOME%\venv" set "VENV=%APPHOME%\venv"
set "LAUNCHER=%APPHOME%\launcher" set "LAUNCHER=%APPHOME%\launcher"
rem An cua so console NGAY TU DAU, ke ca trong luc kiem tra ben duoi — khong
rem chi truoc luc chay app. Moi cho bao loi (echo + pause) ben duoi tu hien
rem lai cua so truoc khi in, de thong bao van doc duoc.
set "CONSOLE_VIS=%REPO%\scripts\console_visibility.ps1"
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 0 >nul 2>&1
)
rem -------------------------------------------------------------------------- rem --------------------------------------------------------------------------
rem 1. Chon trinh thong dich rem 1. Chon trinh thong dich
rem rem
@@ -38,6 +46,7 @@ if exist "%VENV%\Scripts\python.exe" (
) )
if not defined RUNPY ( if not defined RUNPY (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã. echo [LỖI] Không tìm thấy Python. Chạy install.bat trước đã.
echo. echo.
@@ -51,6 +60,7 @@ rem biet la phai chay install.bat.
if not exist "%VENV%\Scripts\python.exe" ( if not exist "%VENV%\Scripts\python.exe" (
!RUNPY! -c "import PySide6" >nul 2>&1 !RUNPY! -c "import PySide6" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã. echo [LỖI] Thư viện chưa được cài. Chạy install.bat trước đã.
echo. echo.
@@ -94,6 +104,7 @@ if /I "%REPO_NAME%"=="cowork_local" (
if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1 if exist "!PKGPATH!\cowork_local" rmdir "!PKGPATH!\cowork_local" >nul 2>&1
mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1 mklink /J "!PKGPATH!\cowork_local" "%REPO%" >nul 2>&1
if errorlevel 1 ( if errorlevel 1 (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LOI] Khong tao duoc lien ket thu muc: echo [LOI] Khong tao duoc lien ket thu muc:
echo "!PKGPATH!\cowork_local" -> "%REPO%" echo "!PKGPATH!\cowork_local" -> "%REPO%"
@@ -112,6 +123,7 @@ rem Chot lai: goi phai THAT SU nhin thay duoc qua duong dan vua dung. Khong co
rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu rem buoc nay thi mot junction hong chi hien ra duoi dang loi Python kho hieu
rem ("'cowork_local' is a package and cannot be directly executed"). rem ("'cowork_local' is a package and cannot be directly executed").
if not exist "!PKGPATH!\cowork_local\__main__.py" ( if not exist "!PKGPATH!\cowork_local\__main__.py" (
if exist "%CONSOLE_VIS%" powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
echo. echo.
echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan: echo [LOI] Khong tim thay cowork_local\__main__.py qua duong dan:
echo "!PKGPATH!" echo "!PKGPATH!"
@@ -138,10 +150,20 @@ if defined PYTHONPATH (
set "PYTHONIOENCODING=utf-8" set "PYTHONIOENCODING=utf-8"
cd /d "%REPO%" cd /d "%REPO%"
rem --------------------------------------------------------------------------
rem 4. Chay app
rem
rem App la GUI (Qt), khong can console — cua so console da bi an tu dau file
rem roi (xem khoi CONSOLE_VIS phia tren), chi hien lai NEU app thoat loi, de
rem thong bao loi ben duoi van doc duoc.
rem --------------------------------------------------------------------------
!RUNPY! -m cowork_local %* !RUNPY! -m cowork_local %*
set "RC=%ERRORLEVEL%" set "RC=%ERRORLEVEL%"
if not "%RC%"=="0" ( if not "%RC%"=="0" (
if exist "%CONSOLE_VIS%" (
powershell -NoProfile -ExecutionPolicy Bypass -File "%CONSOLE_VIS%" -Mode 5 >nul 2>&1
)
echo. echo.
echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên. echo Ứng dụng thoát với mã lỗi %RC%. Xem thông báo ở trên.
echo. echo.
+22
View File
@@ -0,0 +1,22 @@
<#
.SYNOPSIS
Ẩn/hiện cửa sổ console hiện tại — dùng bởi run.bat để không hiện cửa sổ
cmd đen suốt phiên chạy app (app là GUI Qt, không cần console), nhưng vẫn
hiện lại được nếu app thoát lỗi để người dùng đọc thông báo.
.PARAMETER Mode
0 = ẩn (SW_HIDE), 5 = hiện lại (SW_SHOW).
#>
param(
[int]$Mode = 0
)
Add-Type -Name Win32 -Namespace CoworkLocalNative -MemberDefinition @"
[DllImport("user32.dll")] public static extern bool ShowWindow(IntPtr hWnd, int nCmdShow);
[DllImport("kernel32.dll")] public static extern IntPtr GetConsoleWindow();
"@
$hwnd = [CoworkLocalNative.Win32]::GetConsoleWindow()
if ($hwnd -ne [IntPtr]::Zero) {
[CoworkLocalNative.Win32]::ShowWindow($hwnd, $Mode) | Out-Null
}
+32
View File
@@ -73,6 +73,38 @@ _MODERATE_PATTERNS = [
r'\b(test|pytest|jest|mocha)\b', r'\b(test|pytest|jest|mocha)\b',
] ]
# Tools that reach the network over ICMP/raw sockets/direct DNS instead of an
# HTTP(S) connection — none of them read HTTP_PROXY/HTTPS_PROXY, so
# core/deps.py::network_blocked_env()'s proxy-env-var block (the only network
# control this sandbox actually enforces) has no effect on them at all. Used
# by command_bypasses_network_proxy() to deny these BY NAME when the user has
# "Chặn mạng cho lệnh do agent chạy" on, since the proxy trick alone silently
# lets them through (see DF-005 in Defect Management).
_NETWORK_PROXY_BYPASS_PATTERNS = [
r'\bping\b', r'\btracert\b', r'\btraceroute\b', r'\bnslookup\b', r'\bdig\b',
r'\btelnet\b', r'\bftp\b', r'\bsftp\b', r'\bscp\b', r'\bssh\b',
r'\bnc\b', r'\bncat\b', r'\bnetcat\b', r'\barp\b',
r'\btest-netconnection\b', r'\btest-connection\b', r'\bresolve-dnsname\b',
]
def command_bypasses_network_proxy(command: str) -> Optional[str]:
"""Tên công cụ mạng đầu tiên khớp trong ``command`` mà không tôn trọng
HTTP_PROXY/HTTPS_PROXY — None nếu không có công cụ nào như vậy.
``network_blocked_env()`` chỉ set biến proxy, nên chỉ chặn được các công
cụ có ĐỌC biến đó (curl/pip/requests...). ``ping`` (ICMP), ``nslookup``
(DNS trực tiếp), ``ssh``/``ftp`` (TCP thô)... đều đi qua giao thức khác,
biến proxy không có tác dụng gì với chúng — phải chặn riêng theo tên lệnh
khi ``block_network`` đang bật.
"""
cmd_lower = command.lower()
for pattern in _NETWORK_PROXY_BYPASS_PATTERNS:
m = re.search(pattern, cmd_lower, re.IGNORECASE)
if m:
return m.group()
return None
def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult: def classify_command(command: str, is_cowork_mode: bool = True) -> RiskResult:
"""Chấm điểm rủi ro một lệnh shell. """Chấm điểm rủi ro một lệnh shell.
@@ -63,7 +63,7 @@ QUIRK DA DONG DINH (xem case tuong ung ben duoi):
duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False duoc goi, nhung ``step.sub_agents`` KHONG bi dung vao neu ``is_par`` False
— du liệu van con trong step, chi khong hien tren UI. — du liệu van con trong step, chi khong hien tren UI.
* ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG, * ``_add_subagent``: khi ``_available_agent_names()`` tra ve danh sach RONG,
code chuyen sang ``QInputDialog.getText`` thay vi ``getItem`` (dong code chuyen sang ``ask_text`` thay vi ``ask_item`` (dong
401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin 401-402) — nhanh fallback nay chi cham toi khi khong con agent nao (builtin
+ custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot + custom) de chon, hiem khi xay ra tren du lieu that nhung van la mot
nhanh code song. nhanh code song.
@@ -98,7 +98,7 @@ import sys
sys.path.insert(0, REPO_PARENT_PLACEHOLDER) sys.path.insert(0, REPO_PARENT_PLACEHOLDER)
from pathlib import Path as _PP from pathlib import Path as _PP
from PySide6.QtWidgets import QApplication, QInputDialog, QFileDialog, QListWidgetItem from PySide6.QtWidgets import QApplication, QFileDialog, QListWidgetItem
from PySide6.QtCore import Qt from PySide6.QtCore import Qt
from cowork_local.config import CONFIG_DIR from cowork_local.config import CONFIG_DIR
@@ -269,25 +269,31 @@ assert p.sub_list.count() == before_n, "_step None -> _add_subagent la no-op"
print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK") print("CASE_ADD_SUBAGENT_NO_STEP_NOOP_OK")
p._step = pstep p._step = pstep
orig_getItem = QInputDialog.getItem # Panel goi ask_item/ask_text cua ui/dialog_buttons (khong con goi thang ham
orig_getText = QInputDialog.getText # tinh QInputDialog.get*: ham tinh tu dung hop thoai ben trong nen nut "Cancel"
# cua no luon tieng Anh). Patch dung namespace cua mixin dang goi — patch vao
# QInputDialog nhu truoc se KHONG chan duoc gi va hop thoai that se mo ra treo.
from cowork_local.presentation.co4e import node_property_actions_mixin as _acts
orig_getItem = _acts.ask_item
orig_getText = _acts.ask_text
changed_n = {"n": 0} changed_n = {"n": 0}
p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1)) p.changed.connect(lambda: changed_n.__setitem__("n", changed_n["n"] + 1))
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Picked Agent", True)) _acts.ask_item = lambda *a, **k: ("Picked Agent", True)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"] assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"]
assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"] assert [p.sub_list.item(i).text() for i in range(p.sub_list.count())] == ["Picked Agent"]
assert changed_n["n"] == 1 assert changed_n["n"] == 1
print("CASE_ADD_SUBAGENT_PICKED_OK") print("CASE_ADD_SUBAGENT_PICKED_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("", True)) _acts.ask_item = lambda *a, **k: ("", True)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them" assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ten rong (sau strip) -> khong them"
assert changed_n["n"] == 1, "khong them thi khong emit changed" assert changed_n["n"] == 1, "khong them thi khong emit changed"
print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK") print("CASE_ADD_SUBAGENT_EMPTY_NAME_NOOP_OK")
QInputDialog.getItem = staticmethod(lambda *a, **k: ("Should Not Add", False)) _acts.ask_item = lambda *a, **k: ("Should Not Add", False)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them" assert [s.agent for s in pstep.sub_agents] == ["Picked Agent"], "ok=False -> khong them"
print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK") print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
@@ -303,12 +309,12 @@ print("CASE_ADD_SUBAGENT_CANCELLED_NOOP_OK")
# StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong # StepConfigPanel de no roi ve lai dung method ke thua tu mixin - tuong duong
# hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao. # hanh vi voi cach "luu roi gan lai" cu, khong lam yeu di assert nao.
StepConfigPanel._available_agent_names = staticmethod(lambda: []) StepConfigPanel._available_agent_names = staticmethod(lambda: [])
QInputDialog.getText = staticmethod(lambda *a, **k: ("Typed Agent", True)) _acts.ask_text = lambda *a, **k: ("Typed Agent", True)
p._add_subagent() p._add_subagent()
assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"] assert [s.agent for s in pstep.sub_agents] == ["Picked Agent", "Typed Agent"]
del StepConfigPanel._available_agent_names del StepConfigPanel._available_agent_names
QInputDialog.getItem = orig_getItem _acts.ask_item = orig_getItem
QInputDialog.getText = orig_getText _acts.ask_text = orig_getText
print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK") print("CASE_ADD_SUBAGENT_EMPTY_NAMES_USES_GETTEXT_QUIRK_OK")
# ---- _edit_subagent (410-428) ----------------------------------------------- # ---- _edit_subagent (410-428) -----------------------------------------------
@@ -323,7 +329,7 @@ def _fake_get_item(*a, **k):
return ("Renamed", True) return ("Renamed", True)
QInputDialog.getItem = staticmethod(_fake_get_item) _acts.ask_item = _fake_get_item
item0 = p.sub_list.item(0) item0 = p.sub_list.item(0)
p._edit_subagent(item0) p._edit_subagent(item0)
assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0" assert captured["current_index"] == 0, "quirk: cur khong nam trong names -> mo dialog tai index 0"
@@ -335,7 +341,7 @@ foreign_item = QListWidgetItem("not in list")
snapshot = dict(captured) snapshot = dict(captured)
p._edit_subagent(foreign_item) p._edit_subagent(foreign_item)
assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op" assert captured == snapshot, "item khong thuoc sub_list (row=-1) -> no-op"
QInputDialog.getItem = orig_getItem _acts.ask_item = orig_getItem
print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK") print("CASE_EDIT_SUBAGENT_FOREIGN_ITEM_NOOP_OK")
# ---- _del_subagent (430-437) ------------------------------------------------- # ---- _del_subagent (430-437) -------------------------------------------------
@@ -55,6 +55,25 @@ def test_structure_graph_view_builds(ctx):
assert view.qa is not None 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): def test_render_populates_the_scene_and_emits_graph_rendered(ctx, tmp_path):
from cowork_local.presentation.graph.graph_renderer import GraphRenderer 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: def test_add_and_delete_pricing_row_round_trip(qapp, tmp_path, monkeypatch) -> None:
from PySide6.QtWidgets import QInputDialog
ctx = _FakeCtx(tmp_path) ctx = _FakeCtx(tmp_path)
panel = PricingPanel(ctx, on_status_message=lambda _m: None) panel = PricingPanel(ctx, on_status_message=lambda _m: None)
monkeypatch.setattr(QInputDialog, "getText", staticmethod(lambda *a, **k: ("gpt-test", True))) # Panel hỏi tên qua ``ui.dialog_buttons.ask_text`` chứ không gọi thẳng hàm
# tĩnh ``QInputDialog.getText`` (hàm tĩnh tự dựng hộp thoại bên trong nên
# nút Cancel của nó luôn tiếng Anh). Patch nhầm chỗ thì hộp thoại THẬT mở
# ra và test treo cho tới khi hết giờ.
monkeypatch.setattr("cowork_local.ui.dialog_buttons.ask_text",
lambda *a, **k: ("gpt-test", True))
panel._add_pricing_row() panel._add_pricing_row()
assert panel.table.rowCount() == 1 assert panel.table.rowCount() == 1
assert panel.table.item(0, 0).text() == "gpt-test" assert panel.table.item(0, 0).text() == "gpt-test"
+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 Dòng "Made by QuanDH14" từng là widget thường trực ở góc dưới phải, rồi chuyển
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. 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 —
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. 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ỡ Đ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
chỗ cũ thì thành hiện hai lần; chỉ gỡ mà quên thêm thì mất hẳn. 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 from __future__ import annotations
@@ -41,11 +42,13 @@ def test_muc_gioi_thieu_dung_cuoi(dialog):
assert last == tr("settings.group.about") 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 from cowork_local.i18n import tr
assert dialog._about_page.credit_label.text() == tr("app.credit") text = dialog._about_page.version_label.text()
assert "QuanDH14" in dialog._about_page.credit_label.text() assert text == tr("app.version", v=__version__)
assert __version__ in text
def test_muc_gioi_thieu_hien_ten_va_phien_ban(dialog): 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 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(): def test_goc_duoi_phai_hien_phien_ban_tu_cung_mot_nguon():
"""Chốt cả hai đầu: cửa sổ chính không được dựng lại widget ghi công. """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 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. 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] src = (Path(__file__).resolve().parents[2]
/ "presentation" / "shell" / "main_window.py").read_text(encoding="utf-8") / "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 "_credit" not in src, "widget ghi công tác giả đã chuyển vào Cài đặt"
assert "addPermanentWidget" not in src, ( assert src.count("addPermanentWidget") == 1, (
"thanh trạng thái lại có widget thường trực — nếu là thứ khác thì sửa " "góc dưới phải chỉ được mang MỘT widget thường trực — nó chiếm một góc "
"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") "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")
+174
View File
@@ -0,0 +1,174 @@
"""Co4E left column: the skill library must be read ONCE per sidebar reload.
Why this test exists
--------------------
``Co4ESidebarMixin._reload_sidebar`` used to call
``core.skills.skill_prefix_for(name)`` once per skill. Every one of those calls
re-reads the *whole* skill folder from disk (``list_skills()`` +
``builtin_skills()``), so the reload was O(N**2) in the number of skills.
That reload runs on every language switch (``Co4ETab._retranslate`` ->
``_reload_sidebar``). Measured end-to-end on a real library of 121 skills, the
per-name calls cost ~3.8 s of blocked GUI thread — the "app freezes for a few
seconds when I switch Vietnamese -> English" the user reported.
Two different things are guarded here, and they fail for different reasons:
* ``test_reload_sidebar_reads_the_skill_library_once`` — the performance
contract. Red before the fix (the library was read 1 + N times), green after.
* the two equivalence tests — the *correctness* contract. A lookup table is
easy to build subtly wrong, and a wrong one silently changes which skill text
is pushed into an agent prompt. They pin the answer to ``skill_prefix_for``
itself in exactly the cases where a naive ``{name: skill}`` dict diverges:
a name shared by a user skill and a built-in (first match wins, user first),
a skill whose instructions are blank (it does NOT end ``skill_prefix_for``'s
scan, so a later namesake with real instructions must still win), and
lookups by slug / different case / padding / unknown name / empty name.
Those two are characterization tests: green before AND after by design.
"""
from __future__ import annotations
from dataclasses import replace
import pytest
from PySide6.QtCore import Qt
from PySide6.QtWidgets import QListWidget
from cowork_local.core import co4e, skills as skills_mod
from cowork_local.presentation.co4e.co4e_sidebar import Co4ESidebarMixin
class _SidebarHarness(Co4ESidebarMixin):
"""Just enough state to run the real ``_reload_sidebar``.
Building a whole ``Co4ETab`` would drag in config/HOME-dependent module
constants (see ``tests/test_build_co4e_tab.py``); the mixin only touches
the three list widgets, so the real production method runs unchanged here.
"""
def __init__(self) -> None:
"""Create the three palette lists ``_reload_sidebar`` fills in."""
self.wf_list = QListWidget()
self.agent_list = QListWidget()
self.skill_list = QListWidget()
@pytest.fixture
def harness(qapp, monkeypatch):
"""A sidebar harness with the workflow/agent sources stubbed out empty."""
monkeypatch.setattr(co4e, "list_workflows", lambda: [])
monkeypatch.setattr(co4e, "list_custom_agents", lambda: [])
return _SidebarHarness()
def _install_skills(monkeypatch, user, builtin):
"""Replace the two disk-reading skill sources and count how often they run.
Fresh copies are handed out on every call so a caller mutating a returned
``Skill`` cannot make a later call look different.
"""
calls = {"list_skills": 0, "builtin_skills": 0}
def _list_skills(directory=None):
"""Stand-in for ``skills.list_skills`` that records each read."""
calls["list_skills"] += 1
return [replace(s) for s in user]
def _builtin_skills():
"""Stand-in for ``skills.builtin_skills`` that records each read."""
calls["builtin_skills"] += 1
return [replace(s) for s in builtin]
monkeypatch.setattr(skills_mod, "list_skills", _list_skills)
monkeypatch.setattr(skills_mod, "builtin_skills", _builtin_skills)
return calls
def _skill_rows(sidebar):
"""Return ``(item text, payload instructions)`` for every skill palette row."""
return [(sidebar.skill_list.item(i).text(),
sidebar.skill_list.item(i).data(Qt.UserRole)["instructions"])
for i in range(sidebar.skill_list.count())]
def test_reload_sidebar_reads_the_skill_library_once(harness, monkeypatch):
"""One sidebar reload must hit the skill library exactly once, not once per skill."""
user = [skills_mod.Skill(name=f"Skill {i}", instructions=f"Body {i}")
for i in range(8)]
calls = _install_skills(monkeypatch, user, [])
harness._reload_sidebar()
assert harness.skill_list.count() == 8, "every skill must still reach the palette"
assert calls["list_skills"] == 1, (
f"the skill library was read {calls['list_skills']}x for 8 skills — "
"reading it once per skill is the O(N^2) freeze on language switch"
)
assert calls["builtin_skills"] == 1, (
f"built-in skills were read {calls['builtin_skills']}x for 8 skills"
)
def test_skill_payloads_match_skill_prefix_for(harness, monkeypatch):
"""The palette payload must be byte-identical to ``skill_prefix_for``'s answer.
The fixture is deliberately hostile: a name shared by a user skill and a
built-in, a blank-instructions skill followed by a namesake with real
content, and names with padding/odd casing.
"""
user = [
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
skills_mod.Skill(name="Blank First", instructions=" "),
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
skills_mod.Skill(name=" Padded Name ", instructions="PADDED BODY"),
skills_mod.Skill(name="MiXeD CaSe", instructions="MIXED BODY"),
]
builtin = [
skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION"),
skills_mod.Skill(name="Builtin Only", instructions="BUILTIN BODY"),
]
_install_skills(monkeypatch, user, builtin)
harness._reload_sidebar()
expected = [(s.name, skills_mod.skill_prefix_for(s.name)) for s in user + builtin]
assert _skill_rows(harness) == expected
rows = dict(_skill_rows(harness))
# A user skill wins over a built-in of the same name: list_skills() comes
# first and skill_prefix_for() returns the FIRST match, not the last.
assert rows["Shared Name"] == "## Skill: Shared Name\nUSER VERSION"
# A blank-instructions skill does not end the scan, so its later namesake
# supplies the text. A last-write-wins dict would answer '' here.
assert rows["Blank First"] == "## Skill: Blank First\nLATER, WITH CONTENT"
assert rows["Builtin Only"] == "## Skill: Builtin Only\nBUILTIN BODY"
def test_lookup_matches_skill_prefix_for_on_odd_names(monkeypatch):
"""Slug / case / padding / unknown / empty lookups must answer like ``skill_prefix_for``."""
# Imported inside the test on purpose: this symbol is what the fix
# introduces, and a module-level import would turn the pre-fix run into a
# collection error instead of a real assertion failure in the test above.
from cowork_local.presentation.co4e.co4e_sidebar import _skill_prefix_lookup
user = [
skills_mod.Skill(name="Shared Name", instructions="USER VERSION"),
skills_mod.Skill(name="Blank First", instructions=""),
skills_mod.Skill(name="Blank First", instructions="LATER, WITH CONTENT"),
skills_mod.Skill(name="Tiếng Việt / 日本語", instructions="UNICODE BODY"),
skills_mod.Skill(name="", instructions="NAMELESS BODY"),
]
builtin = [skills_mod.Skill(name="Shared Name", instructions="BUILTIN VERSION")]
_install_skills(monkeypatch, user, builtin)
lookup = _skill_prefix_lookup(skills_mod.list_skills() + skills_mod.builtin_skills())
probes = [
"Shared Name", "shared name", "SHARED NAME", " Shared Name ",
"shared-name", # by slug
"Blank First", "blank-first",
"Tiếng Việt / 日本語", "tiếng-việt-日本語",
"", " ", "no-such-skill-anywhere", "skill",
]
for probe in probes:
assert lookup(probe) == skills_mod.skill_prefix_for(probe), probe
+329
View File
@@ -0,0 +1,329 @@
"""Đổi ngôn ngữ thì MỌI chữ trên màn hình phải đổi theo.
Lỗi mà bộ test này khoá lại: `w.setToolTip(tr("k"))` chỉ đúng ở đúng thời điểm
chạy dòng đó. Không có gì áp lại, nên sau khi người dùng đổi ngôn ngữ thì chữ
đứng nguyên ở ngôn ngữ cũ. Vì `DEFAULT_LANGUAGE = "vi"`, triệu chứng người dùng
báo là "chọn English mà nhiều chỗ vẫn tiếng Việt".
Cách kiểm: KHÔNG so chữ với bảng dịch — làm thế thì một nhãn tiếng Việt trùng
chữ với nhãn khác sẽ báo oan (và ngược lại, "OneDrive" giống nhau ở cả ba ngôn
ngữ sẽ lọt). Thay vào đó thay `tr()` bằng hàm trả về một chuỗi MỐC, rồi gọi
`set_language()`. Chỗ nào được áp lại sẽ mang mốc; chỗ nào không mang mốc là chỗ
không có ai áp lại — đó chính là lỗi, và biết chắc chứ không phải suy đoán.
Hai điểm mà một bản kiểm ngây thơ sẽ sai:
* `from ...i18n import tr` COPY tham chiếu vào namespace của từng module, nên
sửa `i18n.tr` một mình là không đủ — phải thay ở mọi module đã import nó.
* Lưới vẽ lại bằng `deleteLater()` để lại widget cũ còn sống tới khi vòng lặp sự
kiện tiêu hoá xong. Không `sendPostedEvents(DeferredDelete)` thì hàng chục
widget bóng ma mang chữ cũ sẽ bị đếm là lỗi trong khi người dùng không hề thấy.
"""
from __future__ import annotations
import sys
import pytest
pytest.importorskip("PySide6", reason="cần PySide6 để dựng cửa sổ thật")
MOC = "\u2063" # invisible separator: không chuỗi hiển thị thật nào chứa nó
#: Khoá cố ý giống nhau ở cả ba ngôn ngữ: tên thương hiệu, tên sản phẩm, ký hiệu,
#: placeholder thuần định dạng, và mã mức độ hiển thị nguyên dạng. Thêm khoá mới
#: 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.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",
"workspace.tab_co4e", "workspace.tab_co4e_tooltip", "workspace.tab_cowork",
"workspace.tab_graphrag", "structure.cmem_ui_open",
"settings.group.anthropic", "settings.group.cowork", "settings.group.structure",
"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",
"cowork.project_label", "connectors.jira_fail", "tools_admin.jira_fail",
# mã mức độ: giữ nguyên dạng ở CẢ BA ngôn ngữ, có chủ ý
"monitoring.severity_critical", "monitoring.severity_info", "monitoring.severity_medium",
}
# ---- dữ liệu: bảng dịch không được thiếu tiếng Nhật ----------------------
def test_moi_khoa_co_du_ba_ngon_ngu():
from cowork_local.i18n import STRINGS
thieu = {k: sorted({"en", "ja", "vi"} - {x for x in v if v.get(x)})
for k, v in STRINGS.items()
if not all(v.get(x) for x in ("en", "ja", "vi"))}
assert thieu == {}, f"khoá thiếu bản dịch: {thieu}"
def test_ban_dich_nhat_khong_phai_chuoi_tieng_anh():
"""`ja` bằng đúng `en` nghĩa là khoá đó chưa được dịch — trừ danh sách miễn.
Đây là nửa còn lại của lỗi người dùng báo: "chọn tiếng Nhật thì vài chỗ hiển
thị tiếng Anh". Nó KHÔNG phải lỗi dây nối (widget vẫn áp lại `tr()` đúng),
mà là lỗ trong dữ liệu dịch — nên phải có cổng riêng canh.
"""
from cowork_local.i18n import STRINGS
chua_dich = sorted(k for k, v in STRINGS.items()
if v.get("en") == v.get("ja") and k not in KHOA_KHONG_CAN_DICH)
assert chua_dich == [], (
"khoá chưa có bản dịch tiếng Nhật (thêm bản dịch, hoặc khai vào "
f"KHOA_KHONG_CAN_DICH kèm lý do): {chua_dich}")
def test_danh_sach_mien_khong_phinh_len_am_tham():
"""Danh sách miễn chỉ được chứa khoá THẬT SỰ giống nhau ở en/ja.
Không có test này thì cách dễ nhất để làm cổng trên xanh lại là nhét khoá
vào danh sách miễn rồi để nguyên đó sau khi đã dịch.
"""
from cowork_local.i18n import STRINGS
thua = sorted(k for k in KHOA_KHONG_CAN_DICH
if k in STRINGS and STRINGS[k].get("en") != STRINGS[k].get("ja"))
assert thua == [], f"khoá đã có bản dịch nhưng vẫn nằm trong danh sách miễn: {thua}"
# ---- lúc chạy: đổi ngôn ngữ thì chữ trên màn hình phải đổi --------------
def _chu_cua(w):
"""Mọi chỗ chữ hiện ra từ một widget, kèm tên thuộc tính."""
from PySide6.QtWidgets import (
QComboBox, QLineEdit, QListWidget, QPlainTextEdit, QTabWidget,
QTableWidget, QTextEdit, QTreeWidget,
)
ra = []
def them(ten, s):
if isinstance(s, str) and s.strip():
ra.append((ten, s))
# QLineEdit.text() là NỘI DUNG người dùng gõ, không phải nhãn giao diện.
if hasattr(w, "text") and not isinstance(w, (QLineEdit, QTextEdit, QPlainTextEdit)):
try:
them("text", w.text())
except RuntimeError:
return ra
for ten in ("windowTitle", "placeholderText", "toolTip", "title", "accessibleName"):
f = getattr(w, ten, None)
if callable(f):
try:
them(ten, f())
except (RuntimeError, TypeError):
pass
if isinstance(w, QTabWidget):
for i in range(w.count()):
them(f"tabText[{i}]", w.tabText(i))
them(f"tabToolTip[{i}]", w.tabToolTip(i))
if isinstance(w, QComboBox):
for i in range(w.count()):
them(f"itemText[{i}]", w.itemText(i))
if isinstance(w, QListWidget):
for i in range(w.count()):
it = w.item(i)
if it is not None:
them(f"item[{i}]", it.text())
them(f"itemTip[{i}]", it.toolTip())
if isinstance(w, QTableWidget):
for c in range(w.columnCount()):
h = w.horizontalHeaderItem(c)
if h is not None:
them(f"hheader[{c}]", h.text())
if isinstance(w, QTreeWidget):
hi = w.headerItem()
if hi is not None:
for c in range(w.columnCount()):
them(f"hheader[{c}]", hi.text(c))
return ra
def _chu_so_huu(w):
"""Lớp widget của chính dự án gần nhất trên đường đi lên.
Một `QPushButton` trần không cho biết nó thuộc màn nào; tổ tiên gần nhất do
dự án định nghĩa thì cho biết, và đó là file cần sửa.
"""
cur = w
while cur is not None:
mod = type(cur).__module__ or ""
if mod.startswith("cowork_local"):
return f"{mod}.{type(cur).__name__}"
cur = cur.parent()
return "?"
def _chup(root, giu):
"""Ảnh chụp mọi chỗ chữ trong cây widget.
``giu`` nhận thêm tham chiếu tới từng widget đã đi qua: wrapper PySide6 bị
thu hồi thì `id()` được cấp lại cho wrapper sau, và vòng quét sẽ tưởng đã
thăm rồi mà dừng sớm — mất phần lớn cây.
"""
from PySide6.QtWidgets import QMenu, QWidget
anh, ngan, da_tham = {}, [root], set()
while ngan:
w = ngan.pop()
if id(w) in da_tham:
continue
da_tham.add(id(w))
giu.append(w)
so_huu = _chu_so_huu(w)
for ten, s in _chu_cua(w):
anh[(id(w), type(w).__name__, so_huu, ten)] = s
try:
ngan.extend([c for c in w.children() if isinstance(c, (QWidget, QMenu))])
except RuntimeError:
pass
return anh
def _thay_tr(moi, cu):
"""Thay `tr` ở MỌI module của dự án đang trỏ tới ``cu``. Trả về số module."""
from cowork_local import i18n as i18n_mod
n = 0
for ten, m in list(sys.modules.items()):
if ten.startswith("cowork_local") and getattr(m, "tr", None) is cu:
setattr(m, "tr", moi)
n += 1
i18n_mod.tr = moi
return n
def _tieu_hoa(qapp):
"""Cho Qt xử lý hết `deleteLater()` để không đếm widget bóng ma."""
from PySide6.QtCore import QEvent
for _ in range(3):
qapp.processEvents()
qapp.sendPostedEvents(None, QEvent.DeferredDelete)
@pytest.fixture
def cua_so(qapp, tmp_path):
"""`MainWindow` thật, đã dựng cả bốn trang dựng-lười.
Trang chưa dựng thì không có chữ nào để kiểm, mà đó lại đúng là nơi lỗi hay
nằm — nên phải gọi `_ensure_page` cho hết.
"""
from cowork_local.presentation.shell.bootstrap import build_config, build_context
from cowork_local.presentation.shell.main_window import MainWindow
duong_dan = tmp_path / "config.json"
build_config(duong_dan)
win = MainWindow(build_context(duong_dan))
win.resize(1500, 950)
for row in range(len(win._page_widgets)):
win._ensure_page(row)
yield win
win.close()
def test_doi_ngon_ngu_thi_khong_con_chu_ngon_ngu_cu(qapp, cua_so):
"""Không chỗ nào giữ lại chữ của ngôn ngữ cũ sau khi đổi ngôn ngữ."""
from cowork_local import i18n as i18n_mod
giu = [] # chống cấp lại id() cho wrapper mới
goc = i18n_mod.tr
ngon_ngu_cu = i18n_mod.get_language()
i18n_mod.set_language("vi")
_tieu_hoa(qapp)
truoc = _chup(cua_so, giu)
def moc(key, **kw):
return MOC + key
try:
assert _thay_tr(moc, goc) > 0, "không thay được tr() ở module nào"
i18n_mod.set_language("en")
_tieu_hoa(qapp)
sau = _chup(cua_so, giu)
finally:
_thay_tr(goc, moc)
i18n_mod.set_language(ngon_ngu_cu)
# Chỉ tính chỗ mà chữ CÓ phụ thuộc ngôn ngữ: "OneDrive" giống nhau ở cả ba
# ngôn ngữ nên không áp lại cũng không ai thấy khác.
phu_thuoc_ngon_ngu = set()
for v in i18n_mod.STRINGS.values():
gia_tri = {v.get("en"), v.get("ja"), v.get("vi")}
if len(gia_tri) > 1:
phu_thuoc_ngon_ngu.update(x for x in gia_tri if x)
# Nhãn nhà cung cấp là DANH TÍNH lấy từ ``config.PROVIDER_LABELS``, không đi
# qua ``tr()`` ở bất kỳ đâu (Agents Admin, model resolver, preview_ai đều
# hiện nguyên dạng như nhau). Một trong số chúng tình cờ trùng chữ với bản
# tiếng Anh của ``settings.group.openai`` — là tiêu đề mục trong Cài đặt,
# một khoá khác hẳn. Không trừ ra thì phép đo báo oan chỗ này mãi mãi.
# Muốn dịch tên nhà cung cấp thì phải sửa ``config.py`` và sửa đồng loạt cả
# năm nơi đang đọc nó — đó là một quyết định sản phẩm, không phải lỗi dây nối.
from cowork_local.config import PROVIDER_LABELS
phu_thuoc_ngon_ngu -= set(PROVIDER_LABELS.values())
con_chu_cu = sorted(
f"{cho[2]} :: {cho[1]}.{cho[3]} = {chu!r}"
for cho, chu in sau.items()
if cho in truoc and not chu.startswith(MOC) and chu in phu_thuoc_ngon_ngu
)
assert con_chu_cu == [], (
"những chỗ này không được áp lại khi đổi ngôn ngữ — dùng bind_text / "
"bind_tip / bind_placeholder / bind_items / bind_dynamic của i18n thay "
"cho setText(tr(...)) một lần:\n " + "\n ".join(con_chu_cu))
def test_phep_do_thuc_su_quet_duoc_man_hinh(qapp, cua_so):
"""Chốt độ phủ của chính phép đo trên.
Một hôm nào đó `_chup` đi sai (đúng lỗi `id()` bị cấp lại đã gặp) thì nó vẫn
trả về một dict rỗng và test trên vẫn xanh — xanh vì không kiểm gì cả.
"""
giu = []
anh = _chup(cua_so, giu)
assert len(anh) > 500, f"chỉ quét được {len(anh)} chỗ chữ — phép đo đang bị cắt"
def test_binding_tu_don_khi_widget_bi_xoa(qapp):
"""Ràng buộc không được giữ widget sống, và không được ném khi widget chết.
Đây là điều kiện để `bind_*` dùng được cho widget dựng lại liên tục (hàng
Kanban, ô lịch) mà không tích thành rác.
"""
from PySide6.QtWidgets import QLabel
from cowork_local import i18n as i18n_mod
lbl = QLabel()
i18n_mod.bind_text(lbl, "app.settings")
truoc = len(i18n_mod._bindings)
assert truoc > 0
lbl.deleteLater()
del lbl
_tieu_hoa(qapp)
i18n_mod._apply_bindings() # không được ném
assert len(i18n_mod._bindings) < truoc, "ràng buộc của widget đã xoá vẫn còn"

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