Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
f6c196ca87 | ||
|
|
a20c7a2d69 | ||
|
|
4f88171c64 | ||
|
|
57af508971 | ||
|
|
d0df96c726 | ||
|
|
10799dc67b | ||
|
|
1b8429e33a | ||
|
|
13e2c22067 |
Binary file not shown.
+1
-1
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
↓
|
||||||
|
_TEMPLATE
|
||||||
|
↓
|
||||||
|
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
|
||||||
|
↓
|
||||||
|
_TEMPLATE / 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**.
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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
@@ -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
|
||||||
|
```
|
||||||
|
|||||||
@@ -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).
|
||||||
|
|||||||
@@ -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}
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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())
|
||||||
|
|||||||
@@ -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)"},
|
||||||
|
|||||||
@@ -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
@@ -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"},
|
||||||
|
|||||||
@@ -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
@@ -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 がコマンドを実行する前に確認する",
|
||||||
|
|||||||
@@ -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
@@ -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."},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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
@@ -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
@@ -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",
|
||||||
|
|||||||
@@ -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": {
|
||||||
|
|||||||
@@ -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}."
|
||||||
|
|||||||
@@ -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", "")))
|
||||||
|
|||||||
@@ -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"].
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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():
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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").
|
||||||
|
|||||||
@@ -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)."""
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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"))
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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."""
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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()
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|
||||||
|
|||||||
@@ -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()
|
||||||
@@ -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) ---
|
||||||
|
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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:
|
||||||
|
|||||||
@@ -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 = ""
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
@@ -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()
|
||||||
@@ -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"
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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")
|
||||||
@@ -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")
|
||||||
|
|||||||
@@ -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
|
||||||
@@ -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
Reference in New Issue
Block a user